Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/wheel_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Wheel smoke test, run from a clean venv by cibuildwheel's test step:
libcppjit.so must locate libclangCppInterOp relative to its own path (the
build tree is gone by test time), and the template instantiation plus the
header check prove the shipped include tree."""

import os

import cppjit
import cppjit_backend

cppjit.cppdef("int wheel_smoke(int x) { return x + 1; }")
assert cppjit.gbl.wheel_smoke(41) == 42

v = cppjit.gbl.std.vector["int"]()
v.push_back(7)
assert v[0] == 7

api = os.path.join(
os.path.dirname(cppjit_backend.__file__), "include", "cpyrt", "API.h"
)
assert os.path.exists(api), api

print("wheel smoke OK")
118 changes: 118 additions & 0 deletions .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: Wheels

# Build the distributable artifacts -- manylinux and macOS arm64 wheels via
# cibuildwheel (config in pyproject.toml) plus the sdist; no index publishing.
# The weekly schedule is a drift canary for the conda-forge/homebrew LLVM.

on:
workflow_dispatch:
pull_request:
paths:
- '.github/workflows/wheels.yml'
- '.github/wheel_smoke.py'
- 'pyproject.toml'
- 'CMakeLists.txt'
- 'cmake/**'
- 'src/interop/**'
- 'python/cppjit/_cpython_cppjit.py'
push:
tags: ['v*']
schedule:
- cron: '30 4 * * 1'

permissions:
contents: read

concurrency:
group: wheels-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
wheels:
name: wheels ${{ matrix.label }}
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-24.04, label: manylinux-x86_64 }
- { os: macos-26, label: macosx-arm64 }
runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v7

- uses: pypa/cibuildwheel@v4.2.0

- uses: actions/upload-artifact@v7
with:
name: wheels-${{ matrix.label }}
path: wheelhouse/*.whl

sdist:
name: sdist
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7

- run: pipx run build --sdist

- uses: actions/upload-artifact@v7
with:
name: sdist
path: dist/*.tar.gz

# Install the linux wheel on a plain runner, outside the manylinux
# container it was built in, and run the full suite against it.
test-wheel:
name: test wheel (full suite)
needs: wheels
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7

- uses: actions/setup-python@v7
with:
python-version: '3.12'

- uses: actions/download-artifact@v8
with:
name: wheels-manylinux-x86_64
path: wheelhouse

- name: Install the LLVM the wheel requires at runtime
# The JIT needs the builtin headers of the wheel's LLVM major;
# the loader detects them from `clang` or `clang-21` on PATH.
run: wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- 21

- name: Install the wheel and the test requirements
run: |
python -m venv wheel-venv
wheel-venv/bin/pip install wheelhouse/cppjit-*cp312*.whl
wheel-venv/bin/pip install -r requirements.txt

- name: Smoke the wheel outside pytest
# pytest captures output at the fd level, so a native abort during
# collection dies silently; this surfaces interpreter-boot errors.
run: |
clang --version
clang-21 -print-resource-dir
wheel-venv/bin/python -X faulthandler .github/wheel_smoke.py

- name: Run the test suite against the installed wheel
env:
# match the CI cells, which run the interpreter under C++20
CPPINTEROP_EXTRA_INTERPRETER_ARGS: -std=c++20
run: |
cd test
make -j4 PYTHON=$GITHUB_WORKSPACE/wheel-venv/bin/python
rc=0
$GITHUB_WORKSPACE/wheel-venv/bin/python -X faulthandler -m pytest -ra \
> pytest.log 2>&1 || rc=$?
tail -80 pytest.log
exit $rc

- name: Fail on dependency-driven skips
# "0 failed" proves nothing about tests that silently stopped
# running; a skip naming a missing module means the wheel venv
# lost dependency coverage.
run: "! grep -iE '^SKIPPED.*(no module named|could not import|module .* not installed)' test/pytest.log"
13 changes: 9 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ set(CPPINTEROP_GIT_TAG "8d624c621a4b95e36ff73ac708c85a768287478f" CACHE STRING "
set(CPPINTEROP_SOURCE_DIR "" CACHE PATH
"Override default CppInterOp built by ExternalProject_Add, with a path to local CppInterOp source")

# Development.Module, not Development: the full component additionally
# requires libpython (Development.Embed), which manylinux images do not
# ship — extension modules only need headers and module-link rules.
set(Python_FIND_VIRTUALENV ONLY)
find_package(Python COMPONENTS Interpreter Development)
find_package(Python COMPONENTS Interpreter Development.Module)
if(NOT Python_FOUND)
set(Python_FIND_VIRTUALENV STANDARD)
find_package(Python COMPONENTS Interpreter Development)
find_package(Python COMPONENTS Interpreter Development.Module)
endif()
if(NOT Python_Development_FOUND)
if(NOT Python_Development.Module_FOUND)
message(FATAL_ERROR "Python development headers not found")
endif()

Expand Down Expand Up @@ -116,11 +119,13 @@ add_library(cppjit SHARED ${CPYRT_SOURCES} ${INTEROP_SOURCES})
add_dependencies(cppjit CppInterOp)

# The wrapper anchors these relative spellings at its own load location,
# falling back to the install prefix (see cppinterop_paths()).
# falling back to the install prefix (see cppinterop_paths()); the clang
# major names the versioned compiler probed for the runtime resource dir.
target_compile_definitions(cppjit PRIVATE
CPPINTEROP_INSTALL_PREFIX="${CPPINTEROP_INSTALL_PREFIX}"
CPPINTEROP_LIBRARY="cppjit_backend/lib/libclangCppInterOp${CMAKE_SHARED_LIBRARY_SUFFIX}"
CPPINTEROP_INCLUDE_DIR="cppjit_backend/include"
CPPJIT_CLANG_MAJOR="${LLVM_VERSION_MAJOR}"
)

target_include_directories(cppjit PRIVATE
Expand Down
31 changes: 31 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,37 @@ cmake.build-type = "Release"
metadata.version.provider = "scikit_build_core.metadata.regex"
metadata.version.input = "python/cppjit/_version.py"

[tool.cibuildwheel]
build = ["cp312-*", "cp313-*", "cp314-*"]
skip = ["*-musllinux*"]
build-verbosity = 1
test-command = "python {project}/.github/wheel_smoke.py"

[tool.cibuildwheel.linux]
archs = ["x86_64"]
manylinux-x86_64-image = "manylinux_2_28"
# LLVM/Clang 21 from conda-forge: built against a glibc within the
# manylinux_2_28 policy, unlike apt.llvm.org or release-tarball builds.
# lld is mandatory: BFD ld mis-relaxes R_X86_64_GOTPCRELX relocations in
# the conda static archives, and the result crashes at the first JIT use.
before-all = """
curl -fsSL --retry 5 -o /tmp/micromamba.tar.bz2 https://micro.mamba.pm/api/micromamba/linux-64/latest
tar -xjf /tmp/micromamba.tar.bz2 -C /usr/local bin/micromamba
export MAMBA_ROOT_PREFIX=/opt/mamba
micromamba create -y -p /opt/llvm -c conda-forge 'llvmdev=21.*' 'clangdev=21.*' lld zstd zlib libxml2
"""
# Environment variables reach the CppInterOp ExternalProject sub-configure
# (find_package(zstd) needs the conda env) and auditwheel (LD_LIBRARY_PATH
# resolves the conda DT_NEEDEDs it grafts); ld.lld comes from PATH.
environment = { CMAKE_ARGS = "-DLLVM_DIR=/opt/llvm/lib/cmake/llvm -DClang_DIR=/opt/llvm/lib/cmake/clang", CMAKE_PREFIX_PATH = "/opt/llvm", LDFLAGS = "-fuse-ld=lld", PATH = "/opt/llvm/bin:$PATH", LD_LIBRARY_PATH = "/opt/llvm/lib" }

[tool.cibuildwheel.macos]
archs = ["arm64"]
before-all = "brew install llvm@21"
# The deployment target must match the Homebrew bottles the wheel grafts
# (bottles target the runner's OS); delocate rejects the default 11.0 label.
environment = { CMAKE_ARGS = "-DLLVM_DIR=/opt/homebrew/opt/llvm@21/lib/cmake/llvm -DClang_DIR=/opt/homebrew/opt/llvm@21/lib/cmake/clang", MACOSX_DEPLOYMENT_TARGET = "26.0" }

[tool.pytest.ini_options]
testpaths = ["test"]
pythonpath = ["test"]
Expand Down
85 changes: 17 additions & 68 deletions python/cppjit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
]

import ctypes
import importlib.util
import os
import sys
import sysconfig
Expand Down Expand Up @@ -334,82 +335,30 @@ def add_library_path(path):
if os.path.exists(apipath) and os.path.exists(os.path.join(apipath, "Python.h")):
add_include_path(apipath)

# add access to extra headers for dispatcher (cpyrt only (?))
# add access to the cpyrt dispatcher API headers, which install next to the
# extension module; anchoring on the extension resolves editable and regular
# installs alike. CPPJIT_API_PATH overrides ("none" disables the lookup).
if not ispypy:
try:
apipath_extra = os.environ["CPPJIT_API_PATH"]
apipath_extra = os.environ.get("CPPJIT_API_PATH")
if apipath_extra:
if os.path.basename(apipath_extra) == "cpyrt":
apipath_extra = os.path.dirname(apipath_extra)
except KeyError:
apipath_extra = None

if apipath_extra is None:
try:
import pkg_resources as pr

d = pr.get_distribution("cpyrt")
for line in d.get_metadata_lines("RECORD"):
if "API.h" in line:
part = line[0 : line.find(",")]

ape = os.path.join(d.location, part)
if os.path.exists(ape):
apipath_extra = os.path.dirname(os.path.dirname(ape))

del part, d, pr
except Exception:
pass

if apipath_extra is None:
# for the monorepo: headers are at cppjit_backend/include/
_cppjit_inc = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "cppjit_backend", "include"
)
if os.path.exists(os.path.join(_cppjit_inc, "cpyrt")):
apipath_extra = _cppjit_inc
del _cppjit_inc

if apipath_extra is None:
ldversion = sysconfig.get_config_var("LDVERSION")
if not ldversion:
ldversion = sys.version[:3]
else:
_spec = importlib.util.find_spec("libcppjit")
if _spec is not None and _spec.origin:
apipath_extra = os.path.join(
os.path.dirname(_spec.origin), "cppjit_backend", "include"
)
del _spec

apipath_extra = os.path.join(
os.path.dirname(apipath), "site", "python" + ldversion
)
if not os.path.exists(os.path.join(apipath_extra, "cpyrt")):
import glob

import libcppjit

ape = os.path.dirname(libcppjit.__file__)
# a "normal" structure finds the include directory up to 3 levels up,
# ie. dropping lib/pythonx.y[md]/site-packages
for i in range(3):
if os.path.exists(os.path.join(ape, "include")):
break
ape = os.path.dirname(ape)

ape = os.path.join(ape, "include")
if os.path.exists(os.path.join(ape, "cpyrt")):
apipath_extra = ape
else:
# add back pythonx.y or site/pythonx.y if present
for p in glob.glob(
os.path.join(ape, "python" + sys.version[:3] + "*")
) + glob.glob(os.path.join(ape, "*", "python" + sys.version[:3] + "*")):
if os.path.exists(os.path.join(p, "cpyrt")):
apipath_extra = p
break

if apipath_extra.lower() != "none":
if not os.path.exists(os.path.join(apipath_extra, "cpyrt")):
if apipath_extra and apipath_extra.lower() != "none":
if os.path.isdir(os.path.join(apipath_extra, "cpyrt")):
add_include_path(apipath_extra)
else:
warnings.warn(
"cpyrt API not found (tried: %s); set CPPJIT_API_PATH envar to the 'cpyrt' API directory to fix"
% apipath_extra
)
else:
add_include_path(apipath_extra)

del apipath_extra

Expand Down
Loading
Loading