From 8ec13f5bfea0871a870c89348ad08b2e650e1624 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Tue, 31 Dec 2024 16:23:57 -0500 Subject: [PATCH] WIP implementing --- .copier-answers.yml | 4 +- .github/CODE_OF_CONDUCT.md | 146 +++---- .github/workflows/build.yml | 62 ++- .github/workflows/copier.yml | 17 + Makefile | 11 +- README.md | 12 +- hatch_cpp/__init__.py | 2 - hatch_cpp/cli.py | 36 -- hatch_cpp/plugin.py | 131 +++--- hatch_cpp/structs.py | 146 +++++++ hatch_cpp/tests/test_project_basic.py | 22 + .../cpp/basic-project/basic.cpp | 4 + .../cpp/basic-project/basic.hpp | 16 + .../test_project_basic/cpp/package-lock.json | 6 - .../tests/test_project_basic/pyproject.toml | 54 ++- hatch_cpp/utils.py | 385 ++++++------------ pyproject.toml | 57 +-- 17 files changed, 531 insertions(+), 580 deletions(-) create mode 100644 .github/workflows/copier.yml delete mode 100644 hatch_cpp/cli.py create mode 100644 hatch_cpp/structs.py create mode 100644 hatch_cpp/tests/test_project_basic.py delete mode 100644 hatch_cpp/tests/test_project_basic/cpp/package-lock.json diff --git a/.copier-answers.yml b/.copier-answers.yml index e80e583..3e3d361 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,8 +1,8 @@ # Changes here will be overwritten by Copier -_commit: b6bd6c7 +_commit: 81e8acd _src_path: git@github.com:python-project-templates/base.git add_extension: python -email: 3105306+timkpaine@users.noreply.github.com +email: t.paine154@gmail.com github: python-project-templates project_description: Hatch plugin for C++ builds project_name: hatch cpp diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 6835fb4..6cafef6 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -2,127 +2,75 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. ## Our Standards -Examples of behavior that contributes to a positive environment for our -community include: +Examples of behavior that contributes to creating a positive environment +include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members -Examples of unacceptable behavior include: +Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission +* Publishing others' private information, such as a physical or electronic + address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a - professional setting + professional setting -## Enforcement Responsibilities +## Our Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. ## Scope -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -t.paine154@gmail.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning +reported by contacting the project team at t.paine154@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb1729d..d9a8ac7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,10 +7,11 @@ on: tags: - v* paths-ignore: - - docs/ - LICENSE - README.md pull_request: + branches: + - main workflow_dispatch: concurrency: @@ -18,7 +19,7 @@ concurrency: cancel-in-progress: true permissions: - contents: write + contents: read checks: write pull-requests: write @@ -28,10 +29,8 @@ jobs: strategy: matrix: - # os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-latest] - python-version: [3.9, 3.11] - node-version: [18.x] + python-version: ["3.9"] steps: - uses: actions/checkout@v4 @@ -40,60 +39,47 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: "pip" + cache: 'pip' cache-dependency-path: 'pyproject.toml' - - name: Install pandoc via brew - run: brew install pandoc - if: ${{ matrix.os == 'macos-latest' }} - - - name: Install pandoc via apt - run: sudo apt install pandoc - if: ${{ matrix.os == 'ubuntu-latest' }} - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - package_json_file: js/package.json - - name: Install dependencies run: make develop - - name: Build - run: make build - - name: Lint run: make lint + - name: Checks + run: make checks + + - name: Build + run: make build + - name: Test - run: make tests - if: ${{ matrix.os == 'ubuntu-latest' }} + run: make coverage - name: Upload test results (Python) uses: actions/upload-artifact@v4 with: - name: py-test-results-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.node-version}} + name: test-results-${{ matrix.os }}-${{ matrix.python-version }} path: junit.xml - if: ${{ matrix.os == 'ubuntu-latest' }} - - - name: Upload test results (JS) - uses: actions/upload-artifact@v4 - with: - name: js-test-results-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.node-version}} - path: js/junit.xml - if: ${{ matrix.os == 'ubuntu-latest' }} + if: ${{ always() }} - name: Publish Unit Test Results uses: EnricoMi/publish-unit-test-result-action@v2 with: - files: | - **/junit.xml - if: ${{ matrix.os == 'ubuntu-latest' }} + files: '**/junit.xml' + if: matrix.os == 'ubuntu-latest' - name: Upload coverage uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} - - name: Twine check + - name: Make dist run: make dist + - uses: actions/upload-artifact@v4 + with: + name: dist-${{matrix.os}} + path: dist + if: matrix.os == 'ubuntu-latest' diff --git a/.github/workflows/copier.yml b/.github/workflows/copier.yml new file mode 100644 index 0000000..871b414 --- /dev/null +++ b/.github/workflows/copier.yml @@ -0,0 +1,17 @@ +name: Copier Updates + +on: + workflow_dispatch: + schedule: + - cron: "0 5 * * 0" + +jobs: + update: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions-ext/copier-update@main + with: + token: ${{ secrets.WORKFLOW_TOKEN }} diff --git a/Makefile b/Makefile index 420d439..627b57c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ develop: ## install dependencies and build library python -m pip install -e .[develop] build: ## build the python library - python -m build . -n + python -m build -n install: ## install library python -m pip install . @@ -34,7 +34,7 @@ format: fix ################ # Other Checks # ################ -.PHONY: check-manifest checks check annotate +.PHONY: check-manifest checks check check-manifest: ## check python sdist manifest with check-manifest check-manifest -v @@ -44,19 +44,16 @@ checks: check-manifest # Alias check: checks -annotate: ## run python type annotation checks with mypy - python -m mypy ./hatch_cpp - ######### # TESTS # ######### .PHONY: test coverage tests test: ## run python tests - python -m pytest -v hatch_cpp/tests --junitxml=junit.xml + python -m pytest -v hatch_cpp/tests coverage: ## run tests and collect test coverage - python -m pytest -v hatch_cpp/tests --junitxml=junit.xml --cov=hatch_cpp --cov-branch --cov-fail-under=50 --cov-report term-missing --cov-report xml + python -m pytest -v hatch_cpp/tests --cov=hatch_cpp --cov-report term-missing --cov-report xml # Alias tests: test diff --git a/README.md b/README.md index f0b02e1..d47e19c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ # hatch-cpp -Hatch plugin for C++ Projects +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) +[![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 + +> [!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 3f6983e..3dc1f76 100644 --- a/hatch_cpp/__init__.py +++ b/hatch_cpp/__init__.py @@ -1,3 +1 @@ __version__ = "0.1.0" - -from .utils import cpp_builder diff --git a/hatch_cpp/cli.py b/hatch_cpp/cli.py deleted file mode 100644 index 3cecead..0000000 --- a/hatch_cpp/cli.py +++ /dev/null @@ -1,36 +0,0 @@ -import os -import os.path -from pathlib import Path -from typing import List, Optional - -from hydra import compose, initialize_config_dir -from hydra.utils import instantiate -from typer import Argument, Typer - -from .config import Configuration - - -def run(path: Path, name: str): - config = Configuration.load(path, name) - config.run() - - -def run_hydra(config_dir="", overrides: Optional[List[str]] = Argument(None)): - with initialize_config_dir(config_dir=os.path.join(os.path.dirname(__file__), "config", "hydra"), version_base=None): - if config_dir: - cfg = compose(config_name="conf", overrides=[], return_hydra_config=True) - searchpaths = cfg["hydra"]["searchpath"] - searchpaths.append(config_dir) - overrides = overrides.copy() + [f"hydra.searchpath=[{','.join(searchpaths)}]"] - cfg = compose(config_name="conf", overrides=overrides) - config = instantiate(cfg) - if not isinstance(config, Configuration): - config = Configuration(**config) - config.run() - - -def main(): - app = Typer() - app.command("run")(run) - app.command("hydra")(run_hydra) - app() diff --git a/hatch_cpp/plugin.py b/hatch_cpp/plugin.py index 8f0bb70..fb88301 100644 --- a/hatch_cpp/plugin.py +++ b/hatch_cpp/plugin.py @@ -1,104 +1,85 @@ -"""The main plugin for hatch_cpp.""" - from __future__ import annotations +import logging import os import typing as t -import warnings -from dataclasses import dataclass, field, fields +from dataclasses import fields -from hatchling.builders.config import BuilderConfig from hatchling.builders.hooks.plugin.interface import BuildHookInterface -from .utils import ( - _get_log, - ensure_targets, - get_build_func, - install_pre_commit_hook, - normalize_kwargs, - should_skip, -) - +from .structs import HatchCppBuildConfig, HatchCppBuildPlan, HatchCppLibrary, HatchCppPlatform -@dataclass -class HatchCppBuildConfig(BuilderConfig): - """Build config values for Hatch Jupyter Builder.""" - - install_pre_commit_hook: str = "" - 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) - optional_editable_build: str = "" +__all__ = ("HatchCppBuildHook",) class HatchCppBuildHook(BuildHookInterface[HatchCppBuildConfig]): """The hatch-cpp build hook.""" PLUGIN_NAME = "hatch-cpp" - _skipped = False + _logger = logging.getLogger(__name__) def initialize(self, version: str, _: dict[str, t.Any]) -> None: """Initialize the plugin.""" - self._skipped = False - log = _get_log() - log.info("Running hatch-cpp") - if self.target_name not in ["wheel", "sdist"]: - log.info("ignoring target name %s", self.target_name) - self._skipped = True + self._logger.info("Running hatch-cpp") + + if self.target_name != "wheel": + self._logger.info("ignoring target name %s", self.target_name) return if os.getenv("SKIP_HATCH_CPP"): - log.info("Skipping the build hook since SKIP_HATCH_CPP was set") - self._skipped = True + self._logger.info("Skipping the build hook since SKIP_HATCH_CPP was set") return - kwargs = normalize_kwargs(self.config) + 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) - should_install_hook = config.install_pre_commit_hook.lower() == "true" - - if version == "editable" and should_install_hook: - install_pre_commit_hook() - - 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) - - log.info("Finished running hatch-cpp") + 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 diff --git a/hatch_cpp/structs.py b/hatch_cpp/structs.py new file mode 100644 index 0000000..ccfab16 --- /dev/null +++ b/hatch_cpp/structs.py @@ -0,0 +1,146 @@ +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_project_basic.py b/hatch_cpp/tests/test_project_basic.py new file mode 100644 index 0000000..b70fff5 --- /dev/null +++ b/hatch_cpp/tests/test_project_basic.py @@ -0,0 +1,22 @@ +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/basic-project/basic.cpp index 6ee3469..a7e840e 100644 --- a/hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.cpp +++ b/hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.cpp @@ -1 +1,5 @@ #include "basic-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/basic-project/basic.hpp index 6f70f09..65cb62e 100644 --- a/hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.hpp +++ b/hatch_cpp/tests/test_project_basic/cpp/basic-project/basic.hpp @@ -1 +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_basic/cpp/package-lock.json b/hatch_cpp/tests/test_project_basic/cpp/package-lock.json deleted file mode 100644 index db50c7c..0000000 --- a/hatch_cpp/tests/test_project_basic/cpp/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "cpp", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/hatch_cpp/tests/test_project_basic/pyproject.toml b/hatch_cpp/tests/test_project_basic/pyproject.toml index 2478ab1..aea842d 100644 --- a/hatch_cpp/tests/test_project_basic/pyproject.toml +++ b/hatch_cpp/tests/test_project_basic/pyproject.toml @@ -13,38 +13,50 @@ dependencies = [ ] [tool.hatch.build] -artifacts = [] +artifacts = [ + "basic_project/*.dll", + "basic_project/*.dylib", + "basic_project/*.so", +] [tool.hatch.build.sources] src = "/" [tool.hatch.build.targets.sdist] -include = [ - "/basic_project", -] -exclude = [] +packages = ["basic_project"] [tool.hatch.build.targets.wheel] -include = [ - "/basic_project", -] -exclude = [ - "/pyproject.toml", -] - -[tool.hatch.build.targets.wheel.shared-data] +packages = ["basic_project"] [tool.hatch.build.hooks.hatch-cpp] -build-function = "hatch_cpp.cpp_builder" -ensured-targets = [] -skip-if-exists = [] -dependencies = [ - "hatch-cpp", +verbose = true +libraries = [ + {name = "basic_project/extension", sources = ["cpp/basic-project/basic.cpp"], include-dirs = ["cpp"]} ] -[tool.hatch.build.hooks.hatch-cpp.build-kwargs] -path = "cpp" -build_cmd = "build" +# 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" diff --git a/hatch_cpp/utils.py b/hatch_cpp/utils.py index d99ea9d..f95bf5e 100644 --- a/hatch_cpp/utils.py +++ b/hatch_cpp/utils.py @@ -1,269 +1,120 @@ from __future__ import annotations -import importlib -import logging -import os -import shlex -import subprocess -import sys -from pathlib import Path -from shutil import which -from typing import Any, Callable, Mapping, cast - -if sys.platform == "win32": # pragma: no cover - from subprocess import list2cmdline -else: - - def list2cmdline(cmd_list: Any) -> str: - """Implementation of list2cmdline for posix systems.""" - return " ".join(map(shlex.quote, cmd_list)) - - -_logger = None - - -def _get_log() -> logging.Logger: - global _logger # noqa: PLW0603 - if _logger: - return _logger # type:ignore[unreachable] - _logger = logging.getLogger(__name__) - _logger.setLevel(logging.INFO) - logging.basicConfig(level=logging.INFO) - return _logger - - -def cpp_builder( - target_name: str, # noqa: ARG001 - version: str, - path: str = ".", - build_dir: str | None = None, - source_dir: str | None = None, - build_cmd: str | None = "build", - force: bool = False, - npm: str | list[Any] | None = None, - editable_build_cmd: str | None = None, -) -> None: - """Build function for managing an npm installation. - - Parameters - ---------- - target_name: str - The build target name ("wheel" or "sdist"). - version: str - The version name ("standard" or "editable"). - path: str, optional - The base path of the node package. Defaults to the current directory. - build_dir: str, optional - The target build directory. If this and source_dir are given, - the JavaScript will only be built if necessary. - source_dir: str, optional - The source code directory. - build_cmd: str, optional - The npm command to build assets to the build_dir. - editable_build_cmd: str, optional. - The npm command to build assets to the build_dir when building in editable mode. - npm: str or list, optional. - The npm executable name, or a tuple of ['node', executable]. - - Notes - ----- - The function is a no-op if the `--skip-npm` cli flag is used - or HATCH_JUPYTER_BUILDER_SKIP_NPM env is set. - """ - - # Check if we are building a wheel from an sdist. - abs_path = Path(path).resolve() - log = _get_log() - - if "--skip-npm" in sys.argv or os.environ.get("HATCH_JUPYTER_BUILDER_SKIP_NPM") == "1": - log.info("Skipping npm install as requested.") - skip_npm = True - if "--skip-npm" in sys.argv: - sys.argv.remove("--skip-npm") - else: - skip_npm = False - - if skip_npm: - log.info("Skipping npm-installation") - return - - if version == "editable": - build_cmd = editable_build_cmd or build_cmd - - if isinstance(npm, str): - npm = [npm] - - # Find a suitable default for the npm command. - if npm is None: - is_yarn = (abs_path / "yarn.lock").exists() - if is_yarn and not which("yarn"): - log.warning("yarn not found, ignoring yarn.lock file") - is_yarn = False - - npm = ["yarn"] if is_yarn else ["npm"] - - npm_cmd = normalize_cmd(npm) - - if build_dir and source_dir and not force: - should_build = is_stale(build_dir, source_dir) - else: - should_build = True - - if should_build: - log.info("Installing build dependencies with npm. This may take a while...") - run([*npm_cmd, "install"], cwd=str(abs_path)) - if build_cmd: - run([*npm_cmd, "run", build_cmd], cwd=str(abs_path)) - else: - log.info("No build required") - - -def is_stale(target: str | Path, source: str | Path) -> bool: - """Test whether the target file/directory is stale based on the source - file/directory. - """ - if not Path(source).exists(): - return False - if not Path(target).exists(): - return True - target_mtime = recursive_mtime(target) or 0 - return compare_recursive_mtime(source, cutoff=target_mtime) - - -def compare_recursive_mtime(path: str | Path, cutoff: float, newest: bool = True) -> bool: - """Compare the newest/oldest mtime for all files in a directory. - Cutoff should be another mtime to be compared against. If an mtime that is - newer/older than the cutoff is found it will return True. - E.g. if newest=True, and a file in path is newer than the cutoff, it will - return True. - """ - path = Path(path) - if path.is_file(): - mt = mtime(path) - if newest: - if mt > cutoff: - return True - elif mt < cutoff: - return True - for dirname, _, filenames in os.walk(str(path), topdown=False): - for filename in filenames: - mt = mtime(Path(dirname) / filename) - if newest: # Put outside of loop? - if mt > cutoff: - return True - elif mt < cutoff: - return True - return False - - -def recursive_mtime(path: str | Path, newest: bool = True) -> float: - """Gets the newest/oldest mtime for all files in a directory.""" - path = Path(path) - if path.is_file(): - return mtime(path) - current_extreme = -1.0 - for dirname, _, filenames in os.walk(str(path), topdown=False): - for filename in filenames: - mt = mtime(Path(dirname) / filename) - if newest: # Put outside of loop? - if mt >= (current_extreme or mt): - current_extreme = mt - elif mt <= (current_extreme or mt): - current_extreme = mt - return current_extreme - - -def mtime(path: str | Path) -> float: - """shorthand for mtime""" - return Path(path).stat().st_mtime - - -def get_build_func(build_func_str: str) -> Callable[..., None]: - """Get a build function by name.""" - # Get the build function by importing it. - mod_name, _, func_name = build_func_str.rpartition(".") - - # If the module fails to import, try importing as a local script. - try: - sys.path.insert(0, str(Path.cwd())) - mod = importlib.import_module(mod_name) - finally: - sys.path.pop(0) - - return cast(Callable[..., None], getattr(mod, func_name)) - - -def normalize_cmd(cmd: str | list[Any]) -> list[str]: - """Normalize a subprocess command.""" - if not isinstance(cmd, (list, tuple)): - cmd = shlex.split(cmd, posix=os.name != "nt") - if not Path(cmd[0]).is_absolute(): - # If a command is not an absolute path find it first. - cmd_path = which(cmd[0]) - if not cmd_path: - msg = f"Aborting. Could not find cmd ({cmd[0]}) in path. " "If command is not expected to be in user's path, " "use an absolute path." - raise ValueError(msg) - cmd[0] = cmd_path - return cmd - - -def normalize_kwargs(kwargs: Mapping[str, Any]) -> dict[str, Any]: - """Normalize the key names in a kwargs input dictionary""" - result = {} - for key, value in kwargs.items(): - if isinstance(value, bool): - value = str(value) # noqa: PLW2901 - result[key.replace("-", "_")] = value - return result - - -def run(cmd: str | list[Any], **kwargs: Any) -> int: - """Echo a command before running it.""" - kwargs.setdefault("shell", os.name == "nt") - cmd = normalize_cmd(cmd) - log = _get_log() - log.info("> %s", list2cmdline(cmd)) - return subprocess.check_call(cmd, **kwargs) - - -def ensure_targets(ensured_targets: list[str]) -> None: - """Ensure that target files are available""" - for target in ensured_targets: - if not Path(target).exists(): - msg = f'Ensured target "{target}" does not exist' - raise ValueError(msg) - _get_log().info("Ensured target(s) exist!") - - -def should_skip(skip_if_exists: Any) -> bool: - """Detect whether all the paths in skip_if_exists exist""" - if not isinstance(skip_if_exists, list) or not len(skip_if_exists): - return False - return all(Path(p).exists() for p in skip_if_exists) - - -def install_pre_commit_hook() -> None: - """Install a pre-commit hook.""" - data = f"""#!/usr/bin/env bash -INSTALL_PYTHON={sys.executable} -ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit) -HERE="$(cd "$(dirname "$0")" && pwd)" -ARGS+=(--hook-dir "$HERE" -- "$@") -exec "$INSTALL_PYTHON" -m pre_commit "${{ARGS[@]}}" -""" - log = _get_log() - if not Path(".git").exists(): - log.warning("Refusing to install pre-commit hook since this is not a git repository") - return - - path = Path(".git/hooks/pre-commit") - if not path.exists(): - log.info("Writing pre-commit hook") - with path.open("w") as fid: - fid.write(data) - else: - log.warning("Refusing to overwrite pre-commit hook") - - mode = path.stat().st_mode - mode |= (mode & 0o444) >> 2 # copy R bits to X - path.chmod(mode) +# import multiprocessing +# import os +# import os.path +# import platform +# import subprocess +# import sys +# from shutil import which +# from skbuild import setup + +# 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") + +# # 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 +# ) + +# 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") diff --git a/pyproject.toml b/pyproject.toml index fdb5495..c9344a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,12 @@ build-backend = "hatchling.build" [project] name = "hatch-cpp" +authors = [{name = "the hatch-cpp authors", email = "t.paine154@gmail.com"}] description = "Hatch plugin for C++ builds" -version = "0.1.0" readme = "README.md" -license = { file = "LICENSE" } +license = { text = "Apache-2.0" } +version = "0.1.0" requires-python = ">=3.9" -authors = [ - { name = "Tim Paine", email = "t.paine154@gmail.com" }, -] keywords = [ "hatch", "python", @@ -23,6 +21,8 @@ keywords = [ classifiers = [ "Development Status :: 3 - Alpha", "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", @@ -39,6 +39,7 @@ dependencies = [ develop = [ "build", "bump-my-version", + "check-manifest", "ruff>=0.3,<0.9", "twine", "wheel", @@ -72,6 +73,28 @@ 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", + "if __name__ == .__main__.:", + "@(abc\\.)?abstractmethod", +] +ignore_errors = true +fail_under = 75 + [tool.hatch.build] artifacts = [] @@ -79,31 +102,13 @@ artifacts = [] src = "/" [tool.hatch.build.targets.sdist] -include = [ - "/hatch_cpp", - "LICENSE", - "README.md", -] -exclude = [ - ".copier-answers.yml", - "/.github", - "/.gitignore", -] +packages = ["hatch_cpp"] [tool.hatch.build.targets.wheel] -include = [ - "/hatch_cpp", -] -exclude = [ - ".copier-answers.yml", - "/.github", - "/.gitignore", - "/pyproject.toml", -] - -[tool.hatch.build.targets.wheel.shared-data] +packages = ["hatch_cpp"] [tool.pytest.ini_options] +addopts = ["-vvv", "--junitxml=junit.xml"] asyncio_mode = "strict" testpaths = "hatch_cpp/tests"