Skip to content

feat: Add cross-compilation support for sdist packages with native extensions - #1363

Draft
xangcastle wants to merge 32 commits into
mainfrom
xangcastle/cross-sdist
Draft

feat: Add cross-compilation support for sdist packages with native extensions#1363
xangcastle wants to merge 32 commits into
mainfrom
xangcastle/cross-sdist

Conversation

@xangcastle

@xangcastle xangcastle commented Jul 23, 2026

Copy link
Copy Markdown
Member

Enable pep517_native_whl to cross-compile Python sdists containing C or C++ extensions when the target platform differs from the exec host.
The native_build_toolchain sentinel is now optional, so a missing resolution signals cross-compilation mode and falls through to a user-registered cross CC toolchain (such as toolchains_llvm) instead of hard-failing.
A new cc_layer.bzl module extracts compiler paths, CFLAGS, and LDFLAGS from the resolved cross CC toolchain at analysis time, and build_helper.py generates compiler wrappers that re-inject -target/--sysroot identity flags, filter incompatible linker flags from the exec host, and override LDSHARED to use the cross CC toolchain's link flags. The target interpreter's _sysconfigdata is loaded via _PYTHON_SYSCONFIGDATA_NAME so setuptools produces correct EXT_SUFFIX and SOABI tags, and a post-build check validates the wheel's platform tag against the target before accepting the output.


Changes are visible to end-users: yes/no

  • Searched for relevant documentation and updated as needed: yes/no
  • Breaking change (forces users to change their own code or config): yes/no
  • Suggested release notes appear below: yes/no

Test plan

  • Covered by existing test cases
  • New test cases added
  • Manual testing; please provide instructions so we can reproduce:

@aspect-workflows

aspect-workflows Bot commented Jul 23, 2026

Copy link
Copy Markdown

✨ Aspect Workflows Tasks

📅 Tue Aug 11 08:16:09 UTC 2026

✅ 44 successful tasks

  • ✅ buildifier · ⏱ 17.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ gazelle · ⏱ 19.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ test-e2e-bazel-8 [test] · ⏱ 5m 34s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (267/267 passed)
  • ✅ test-e2e-bazel-9 [test] · ⏱ 3m 53s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (263/263 passed)
  • ✅ test-e2e-crossbuild-bazel-8 [test] · ⏱ 7m · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (62/62 passed)
  • ✅ test-e2e-crossbuild-bazel-9 [test] · ⏱ 6m 12s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (62/62 passed)
  • ✅ test-e2e-interpreter-build-config-bazel-8 [test] · ⏱ 24.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-interpreter-build-config-bazel-9 [test] · ⏱ 33.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-interpreter-input-validation-bazel-8 [test] · ⏱ 19s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-interpreter-input-validation-bazel-9 [test] · ⏱ 45.4s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-interpreter-runtime-metadata-bazel-8 [test] · ⏱ 28.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (2/2 passed · 2 cached)
  • ✅ test-e2e-interpreter-runtime-metadata-bazel-9 [test] · ⏱ 37s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (2/2 passed)
  • ✅ test-e2e-interpreter-toolchain-settings-bazel-8 [test] · ⏱ 16.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-interpreter-toolchain-settings-bazel-9 [test] · ⏱ 49.5s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-rules-proto-grpc-python-bazel-8 [test] · ⏱ 33.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-rules-proto-grpc-python-bazel-9 [test] · ⏱ 1m 45s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-e2e-rules-python-interop-bazel-8 [test] · ⏱ 39.8s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (11/11 passed)
  • ✅ test-e2e-rules-python-interop-bazel-9 [test] · ⏱ 55.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (11/11 passed)
  • ✅ test-e2e-rules-python-provider-compat-bazel-8 [test] · ⏱ 25.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (3/3 passed)
  • ✅ test-e2e-rules-python-provider-compat-bazel-9 [test] · ⏱ 51.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (3/3 passed)
  • ✅ test-examples-debugger-bazel-8 [test] · ⏱ 26.8s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-debugger-bazel-9 [test] · ⏱ 37.5s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-dev_deps-bazel-8 [test] · ⏱ 31.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-dev_deps-bazel-9 [test] · ⏱ 1m 21s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-django-bazel-8 [test] · ⏱ 28.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-django-bazel-9 [test] · ⏱ 40.8s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-examples-multi_version-bazel-8 [test] · ⏱ 26.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (2/2 passed)
  • ✅ test-examples-multi_version-bazel-9 [test] · ⏱ 49.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (2/2 passed)
  • ✅ test-examples-protobuf-bazel-8 [test] · ⏱ 1m 11s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-protobuf-bazel-9 [test] · ⏱ 1m 28s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-py_binary-bazel-8 [test] · ⏱ 23.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-py_binary-bazel-9 [test] · ⏱ 34.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-examples-py_pex_binary-bazel-8 [test] · ⏱ 26.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-py_pex_binary-bazel-9 [test] · ⏱ 38.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-examples-py_venv-bazel-8 [test] · ⏱ 21.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (3/3 passed)
  • ✅ test-examples-py_venv-bazel-9 [test] · ⏱ 1m 24s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (3/3 passed)
  • ✅ test-examples-pytest-bazel-8 [test] · ⏱ 44s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (11/11 passed)
  • ✅ test-examples-pytest-bazel-9 [test] · ⏱ 1m 2s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (11/11 passed)
  • ✅ test-examples-uv_pip_compile-bazel-8 [test] · ⏱ 26.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-uv_pip_compile-bazel-9 [test] · ⏱ 41.8s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-virtual_deps-bazel-8 [test] · ⏱ 26.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-examples-virtual_deps-bazel-9 [test] · ⏱ 37s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)
  • ✅ test-root-bazel-8 [test] · ⏱ 3m 25s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (298/298 passed)
  • ✅ test-root-bazel-9 [test] · ⏱ 3m 30s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (297/297 passed)

⏱ Last updated Tue Aug 11 08:25:13 UTC 2026 · 📊 GitHub API quota 538/15,000 (4% used, resets in 50m)
🚀 Powered by Aspect CLI (v2026.28.2)  |  Aspect Build · X · LinkedIn · YouTube

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

py_binary startup benchmark

Version Mean (ms) Median (ms) ± stddev vs BCR vs main Build (s)
BCR 1.11.7 (baseline) 181.388 180.856 ±4.013 34.20
HEAD main 59.673 59.174 ±2.507 -67.1% 10.38
This PR 58.203 57.927 ±1.120 -67.9% -2.5% 7.79

Measured with hyperfine --warmup 5 --runs 50 on Linux
Gate: PR vs HEAD main (threshold: 10%). BCR is shown only as a historical baseline.
Build time: cold bazel build //:bench with isolated output base, no disk cache.

sys.path quality

Version sys.path entries distinct site-packages roots duplicate realpaths
BCR 1.11.7 (baseline) 6 1 0
HEAD main 7 2 0
This PR 7 2 0

sys.path quality measured by bench_syspath inside the assembled venv. Duplicate realpaths indicate symlink redundancy; many distinct site-packages roots suggest an inefficient venv layout.

Bazel analysis benchmark

Version Mean (ms) Median (ms) ± stddev vs BCR vs main Targets Actions
BCR 2.0.0-alpha.5 (baseline) 10585.419 10521.467 ±174.010 301 13672
HEAD main 9213.470 9212.723 ±161.868 -13.0% 301 13740
This PR 8889.449 8943.371 ±159.616 -16.0% -3.5% 301 13740

Measured with hyperfine --warmup 1 --runs 10 on Linux
Gate: PR vs HEAD main (threshold: 10%). BCR is shown only as a historical baseline.
Command: cold bazel build --nobuild //workspace/... with isolated output base, no disk cache.

@xangcastle
xangcastle requested a review from jbedard July 23, 2026 01:39
@xangcastle
xangcastle force-pushed the xangcastle/cross-sdist branch 2 times, most recently from 5e9c4f1 to cd6dfa7 Compare July 23, 2026 05:33
@xangcastle xangcastle changed the title feat: cross sdist build feat: Add cross-compilation support for sdist packages with native extensions Jul 23, 2026

@tamird tamird left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing this draft at the author's explicit request.

rules_pycross already provides the native CC layer, separate execution and target Python interpreters, PEP 517 cross-build environment, and target-aware sysconfig that this change reimplements. Its v2 alpha also exposes those primitives through a public backend integration API. [0] [1] [2] [3]

The proposed implementation instead infers cross-compilation from an absent optional native toolchain, omits target native dependency and runtime closures, hard-codes macOS deployment tags, and rejects valid Windows wheels. The Linux-only fixture checks the ELF architecture and filename without proving that a cross-built native extension actually loads.

Please first evaluate integration with rules_pycross, including the v2 alpha's transitive-dependency compatibility. If that integration is not viable, document the actual blocker and propose the smallest reusable upstream interface before introducing a parallel cross-compilation implementation. [4]

[0] https://github.com/jvolkman/rules_pycross/blob/v2.0.0-alpha.2/pycross/backend.bzl#L63-L83
[1] https://github.com/jvolkman/rules_pycross/blob/74ee87c7d8eab76a673c07df3ec9e55d1e629e49/pycross/toolchain.bzl#L33-L55
[2] https://github.com/jvolkman/rules_pycross/blob/74ee87c7d8eab76a673c07df3ec9e55d1e629e49/pycross/private/build/actions/cc_layer.bzl#L126-L182
[3] https://github.com/jvolkman/rules_pycross/blob/74ee87c7d8eab76a673c07df3ec9e55d1e629e49/pycross/private/build/tools/utils/sysconfig_utils.py#L97-L169
[4] https://github.com/bazelbuild/bazel-central-registry/blob/main/modules/rules_pycross/2.0.0-alpha.2/MODULE.bazel

— tamirdex

Comment thread uv/private/pep517_whl/rule.bzl Outdated

target_os, target_cpu = get_target_platform(ctx)

return struct(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns compiler tools and flags, but not the CcInfo headers, dependent static/shared libraries, or target C++ runtime that a real source-built extension requires. The wheel action cannot materialize that dependency closure; inspecting the toy geohash ELF cannot establish that the resulting extension links or loads. The existing pycross layer explicitly gathers all three. Please compose with that layer or explain and test the real alternative. [0]

[0] https://github.com/jvolkman/rules_pycross/blob/74ee87c7d8eab76a673c07df3ec9e55d1e629e49/pycross/private/build/actions/cc_layer.bzl#L126-L182

— tamirdex

sysconfig_file = _find_sysconfigdata(runtime)
if sysconfig_file:
extra_inputs.append(depset([sysconfig_file]))
env["RULES_PY_TARGET_SYSCONFIGDATA"] = sysconfig_file.path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying one target _sysconfigdata file into a build still executed by the host interpreter does not create a target Python environment. Build backends continue to observe host interpreter identity, packaging tags, and platform behavior; the later validation checks only the final platform segment, so a host cp313 ABI can pass for a cp312 target with the same OS and CPU. pycross carries both interpreters and builds the appropriate cross environment instead. [0]

[0] https://github.com/jvolkman/rules_pycross/blob/74ee87c7d8eab76a673c07df3ec9e55d1e629e49/pycross/private/build/actions/pep517_action.bzl#L81-L103

— tamirdex

return "linux-" + _PYTHON_CPU_MAP.get(target_cpu, target_cpu)
if target_os == "darwin":
cpu = "arm64" if target_cpu == "aarch64" else target_cpu
return "macosx-11.0-" + cpu

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The macOS deployment version is a property of the target interpreter/SDK, not always 11.0. Hard-coding it produces an incorrect compatibility tag for targets requiring another deployment version. Derive the platform and deployment target from target sysconfig, as pycross already does, and add an actual Darwin cross-build regression. [0]

[0] https://github.com/jvolkman/rules_pycross/blob/74ee87c7d8eab76a673c07df3ec9e55d1e629e49/pycross/private/build/tools/utils/sysconfig_utils.py#L97-L169

— tamirdex

)
exit(1)

if expected_cpu not in platform_tag:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A valid Windows x86-64 wheel has platform tag win_amd64, but _expected_cpu_in_tag returns x86_64 here. Consequently every such wheel fails this validation despite being correct; Windows x86 has the analogous win32/i686 mismatch. Conversely unrecognized target OS/CPU values silently skip validation above. Derive supported wheel tags from actual target metadata and fail closed for unsupported targets.

— tamirdex

Comment thread e2e/cases/uv-deps-650/crossbuild/BUILD.bazel Outdated
@xangcastle
xangcastle force-pushed the xangcastle/cross-sdist branch 2 times, most recently from c1e9648 to 4a95d04 Compare July 23, 2026 17:36
@xangcastle
xangcastle marked this pull request as ready for review July 23, 2026 18:16
@jbedard
jbedard requested a review from tamird July 23, 2026 22:20

@tamird tamird left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing because Jason explicitly requested review of 9f3b372.

The new execution transition still selects host_platform and repository-time host libc, rather than the wheel action’s actual execution platform. The associated frontend test removes its execution-platform assertion. This cannot build correctly under heterogeneous remote execution; the new documentation itself confirms that the design assumes exec == host.

The five existing review threads remain current and unanswered. The replacement CC layer does not stage transitive CcInfo headers, native libraries or runtime dependencies; the PEP 517 frontend still runs the host interpreter with only a target sysconfig file; macOS wheel tags still hard-code deployment target 11.0; and wheel validation still rejects valid win_amd64 tags. The Linux fixture checks one patched extension’s ELF and filename but never executes the cross-built artifact or verifies a native dependency, target Python ABI, Darwin or remote worker.

rules_pycross already publicly exports target-aware CC extraction, PEP 517 actions, wheel repair and execution-platform transitions. Please evaluate composing those existing primitives before maintaining a second incomplete cross builder. If a dependency or compatibility issue prevents reuse, identify it and add real target-runtime and remote-execution coverage before calling this general cross-compilation.

https://github.com/jvolkman/rules_pycross/blob/v2.0.0-alpha.2/pycross/backend.bzl#L28-L39
https://github.com/jvolkman/rules_pycross/blob/v2.0.0-alpha.2/pycross/backend.bzl#L63-L83

— tamirdex

@jbedard
jbedard marked this pull request as draft July 24, 2026 19:14
@xangcastle
xangcastle force-pushed the xangcastle/cross-sdist branch 3 times, most recently from 01fdffa to 9061079 Compare August 6, 2026 17:59
xangcastle added a commit that referenced this pull request Aug 11, 2026
Ports four cases from rules_pycross's e2e suite, exercising the
**existing** sdist→wheel pipeline (host builds only — no
cross-compilation involved). Split out of #1363, where these cases don't
depend on the cross feature; every test passes against current `main`
unchanged.

## Cases

- **pycross-setuptools** (`build_setuptools`): three setuptools
C-extension sdists — PyYAML (Cython-generated extension, with an
observable `PYYAML_FORCE_LIBYAML=0` env override), setproctitle (plain
C, `pre_build_patches`), zstandard (vendored libzstd, `resource_set`).
- **pycross-patches** (`patches_and_hooks`): pre-build and post-install
patch phases stacked on setproctitle — the post-install hunk carries the
pre-build patch's output as context, so an ordering regression fails the
build itself.
- **pycross-pure-python** (`build_pure_python`): hatchling and flit-core
backends, runtime imports, site-packages placement hygiene, plus a
`collect_wheels` matrix asserting anyarch wheels stay `-none-any` under
non-host platform transitions (exec-platform resolution of the build
tooling, no native toolchain needed).
- **pycross-distutils-probe**: build-action env hygiene — a fresh child
interpreter spawned by the backend must resolve `distutils` on Python
3.12+, guarding `rule.bzl`'s `_INHERITED_PYTHON_ENV` filter. Fails only
in the child, so it's invisible to a plain "does it build" check.

Shared `tools/`: `collect_wheels` macro (adds the wheel-tag assertion
the rules_pycross original lacks) and `check_wheel_tags.py`.

## Not included

`pycross-setuptools`' cross matrix (`native_wheels*`,
`check_wheel_native.py`): building a C extension for a non-host platform
needs `native_build_toolchain_type` resolution for that platform, which
lands with #1363. The second commit documents that scope cut.

---

### Changes are visible to end-users: no


### Test plan

- New test cases added

---------

Co-authored-by: Jason Bedard <jason+github@jbedard.ca>
@xangcastle
xangcastle force-pushed the xangcastle/cross-sdist branch from 324b8e6 to 2273078 Compare August 11, 2026 04:44
pycross-setuptools' native_wheels matrix duplicates
e2e/crossbuild/zstandard (same package, and crossbuild goes further:
QEMU execution plus a byte-for-byte diff against the official wheel).
Its marginal value — the cross path under e2e/cases' own CC toolchain
and shared-hub setup — lands as its own small PR after this one.
Recoverable from commit 2273078.
xangcastle and others added 4 commits August 11, 2026 02:15
geohash was the workspace's first case and kept its pyproject/uv.lock
at the root under a bare pypi_crossbuild hub while every later case
adopted the one-dir-one-hub pattern. Move the pair into geohash/,
rename the hub to pypi_crossbuild_geohash, and align the project name
and dep_group with the crossbuild_<pkg> convention.
Port of rules_pycross's e2e suite (build_setuptools, patches_and_hooks,
build_pure_python and the distutils probe), relocated into the
crossbuild workspace.

(originally commit 4869b33, merged to
main squashed in #1413 / 05504ac; re-homed here with the content of
the original port)
The four rules_pycross ports moved from e2e/cases into the crossbuild
workspace, where the cross toolchains they exercise actually live:

- restore the pycross-setuptools native_wheels matrix deferred out of
  #1413 (zstandard rebuilt for amd64/arm64 with ELF and wheel-tag
  assertions) — under this workspace's registered cross CC toolchain it
  runs where it couldn't on main
- carry the reviewed refinements from #1413 over the original port
- pin setproctitle to -std=gnu17: gcc_toolchain's GCC defaults to C23,
  where `bool` is a keyword and 1.3.2's `typedef char bool` breaks —
  coverage the e2e/cases host toolchain never gave us
- drop the cases, their MODULE includes and the collect_wheels tooling
  from e2e/cases
uv-deps-650/crossbuild's test_crossbuild_pyc.sh guarded whl_install's
compileall picking the exec-platform interpreter under a cross
transition; its removal left that regression uncovered. The restored
guard is stronger than the original "it builds" check:

- the platform pins //uv/private/pyc:precompile=True, so the coverage
  survives a future default flip instead of silently draining
- the sh_test asserts .pyc files actually exist in the cross-built
  install tree, catching exec_matches_target silently skipping
  compileall — verified to fail when precompile is off

Asserted over the binary's runfiles rather than an image layer:
py_image_layer deliberately drops __pycache__ from layer mtrees.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants