diff --git a/.github/workflows/grog.yml b/.github/workflows/grog.yml new file mode 100644 index 00000000..ec35bd01 --- /dev/null +++ b/.github/workflows/grog.yml @@ -0,0 +1,40 @@ +name: Grog (Wheel Install Smoke) + +on: + pull_request: + branches: + - master + - main + push: + branches: + - master + - main + +jobs: + grog: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install grog + shell: bash + run: | + curl -L https://grog.build/latest/grog-linux-amd64 -o grog + chmod +x grog + sudo mv grog /usr/local/bin/grog + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run grog install smoke tests + run: grog test diff --git a/.gitignore b/.gitignore index d49cb08b..5fe20223 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,13 @@ venv.bak/ .coverage htmlcov/ +# Grog +.grog/ +.grog-cache/ + +# Hatch build hook intermediates +.hatch-ext/ + # Jupyter Notebook .ipynb_checkpoints diff --git a/BUILD.yaml b/BUILD.yaml new file mode 100644 index 00000000..bfe66433 --- /dev/null +++ b/BUILD.yaml @@ -0,0 +1,54 @@ +targets: + - name: wheel_py + command: uv build --wheel --out-dir dist/grog/py --clear --no-create-gitignore + inputs: + - pyproject.toml + - hatch_build.py + - visdet/** + - README.md + - LICENSE + - MANIFEST.in + outputs: + - dir::dist/grog/py + + - name: wheel_cpp + command: VISDET_BUILD_CPP_EXT=1 uv build --wheel --out-dir dist/grog/cpp --clear --no-create-gitignore + inputs: + - pyproject.toml + - hatch_build.py + - visdet/** + - README.md + - LICENSE + - MANIFEST.in + outputs: + - dir::dist/grog/cpp + + - name: install_smoke_py_test + dependencies: + - :wheel_py + command: | + set -e + ROOT="$(pwd)" + rm -rf "$ROOT/.grog/venv-py" + uv venv "$ROOT/.grog/venv-py" + uv pip install --python "$ROOT/.grog/venv-py" "$ROOT/dist/grog/py"/*.whl + (cd /tmp && "$ROOT/.grog/venv-py/bin/python" -c "import visdet; from visdet._ext_demo import add, HAS_EXT; assert add(1,2)==3; assert HAS_EXT is False") + mkdir -p "$ROOT/.grog/out" + touch "$ROOT/.grog/out/install_smoke_py.ok" + outputs: + - .grog/out/install_smoke_py.ok + + - name: install_smoke_cpp_test + dependencies: + - :wheel_cpp + command: | + set -e + ROOT="$(pwd)" + rm -rf "$ROOT/.grog/venv-cpp" + uv venv "$ROOT/.grog/venv-cpp" + uv pip install --python "$ROOT/.grog/venv-cpp" "$ROOT/dist/grog/cpp"/*.whl + (cd /tmp && "$ROOT/.grog/venv-cpp/bin/python" -c "import visdet; from visdet._ext_demo import add, HAS_EXT; assert add(2,3)==5; assert HAS_EXT is True") + mkdir -p "$ROOT/.grog/out" + touch "$ROOT/.grog/out/install_smoke_cpp.ok" + outputs: + - .grog/out/install_smoke_cpp.ok diff --git a/grog.toml b/grog.toml new file mode 100644 index 00000000..b630966b --- /dev/null +++ b/grog.toml @@ -0,0 +1,2 @@ +# Grog configuration +# Targets are defined in BUILD.yaml at repo root. diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 00000000..0ca9a379 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sysconfig +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CustomBuildHook(BuildHookInterface): + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + # Keep installs working everywhere by default. + if os.environ.get("VISDET_BUILD_CPP_EXT", "0") != "1": + return + + # Only build compiled artifacts for wheels. + if self.target_name != "wheel": + return + + root = Path(self.root) + build_tmp = root / ".hatch-ext" / "cpp" + build_tmp.mkdir(parents=True, exist_ok=True) + + src = build_tmp / "demo_ext.c" + src.write_text( + """ +#include + +static PyObject* demo_add(PyObject* self, PyObject* args) { + long a; + long b; + if (!PyArg_ParseTuple(args, \"ll\", &a, &b)) { + return NULL; + } + return PyLong_FromLong(a + b); +} + +static PyMethodDef DemoMethods[] = { + {\"add\", demo_add, METH_VARARGS, \"Add two integers.\"}, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef demomodule = { + PyModuleDef_HEAD_INIT, + \"_demo_ext\", + \"visdet demo C extension\", + -1, + DemoMethods +}; + +PyMODINIT_FUNC PyInit__demo_ext(void) { + return PyModule_Create(&demomodule); +} +""".lstrip(), + encoding="utf-8", + ) + + ext_suffix = sysconfig.get_config_var("EXT_SUFFIX") + if not ext_suffix: + self.app.display_warning("Could not determine EXT_SUFFIX; skipping C extension build") + return + + out = build_tmp / f"_demo_ext{ext_suffix}" + + include_dir = sysconfig.get_path("include") + ldshared = sysconfig.get_config_var("LDSHARED") or "cc -shared" + cflags = sysconfig.get_config_var("CFLAGS") or "" + cppflags = sysconfig.get_config_var("CPPFLAGS") or "" + + cmd = ( + shlex.split(ldshared) + + shlex.split(cflags) + + shlex.split(cppflags) + + [f"-I{include_dir}", "-o", str(out), str(src)] + ) + + self.app.display_info(f"Building C extension: {out.name}") + subprocess.check_call(cmd, cwd=str(root)) + + # Ensure hatchling includes the generated artifact in the wheel (without polluting the source tree). + build_data.setdefault("force_include", {})[str(out)] = f"visdet/_ext_demo/{out.name}" + + # Avoid accidentally shipping .dSYM bundles (macOS). + dsym = out.with_suffix(out.suffix + ".dSYM") + if dsym.exists(): + shutil.rmtree(dsym) diff --git a/pyproject.toml b/pyproject.toml index c8281cb7..db59cff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,12 +105,25 @@ Documentation = "https://binitai.github.io/visdet/" # Exclude nested pyproject.toml that was part of old workspace structure exclude = [ "visdet/pyproject.toml", + # Don't ship build intermediates + ".hatch-ext/**", + # Don't ship macOS debug bundles + "visdet/_ext_demo/*.dSYM/**", +] + +# Hatchling has built-in ignores for directories like `dist/`, which clashes +# with our internal module path `visdet/engine/dist/`. Force-include it. +artifacts = [ + "visdet/engine/dist/**", ] [tool.hatch.build.targets.wheel] # The package code is in visdet/ - tell hatchling where to find it packages = ["visdet"] +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + [tool.ruff] line-length = 120 target-version = "py310" diff --git a/visdet/_ext_demo/__init__.py b/visdet/_ext_demo/__init__.py new file mode 100644 index 00000000..fac25668 --- /dev/null +++ b/visdet/_ext_demo/__init__.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import importlib +import importlib.util + +_DEMO_EXT_SPEC = importlib.util.find_spec("visdet._ext_demo._demo_ext") +HAS_EXT = _DEMO_EXT_SPEC is not None + +if HAS_EXT: + _demo_ext = importlib.import_module("visdet._ext_demo._demo_ext") +else: + _demo_ext = None + + +def add(a: int, b: int) -> int: + """Add two integers. + + Uses the compiled extension when available, otherwise falls back to Python. + """ + + if _demo_ext is None: + return int(a) + int(b) + + return int(_demo_ext.add(int(a), int(b)))