diff --git a/.github/workflows/build-python-version.yml b/.github/workflows/build-python-version.yml index 3bb6f6c..977f4dd 100644 --- a/.github/workflows/build-python-version.yml +++ b/.github/workflows/build-python-version.yml @@ -22,16 +22,17 @@ env: PYTHON_VERSION: ${{ inputs.python_version || github.event.inputs.python_version }} PYTHON_DIST_RELEASE: 20260203 # https://github.com/astral-sh/python-build-standalone/releases +permissions: + contents: read + jobs: build-darwin: name: Build Python for iOS and macOS runs-on: macos-15 - permissions: - contents: write - steps: - name: Checkout uses: actions/checkout@v4 + - name: Derive short Python version shell: bash run: | @@ -42,9 +43,6 @@ jobs: with: python-version: ${{ env.PYTHON_VERSION_SHORT }} - - name: Show Python version - run: python --version - - name: Build Python for iOS and macOS working-directory: darwin shell: bash @@ -71,18 +69,27 @@ jobs: build-android: name: Build Python for Android runs-on: ubuntu-latest - permissions: - contents: write steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + - name: Derive short Python version shell: bash run: | echo "PYTHON_VERSION_SHORT=$(echo "$PYTHON_VERSION" | cut -d. -f1,2)" >> "$GITHUB_ENV" - - uses: actions/setup-python@v6 + + - name: Setup Python + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - - run: python --version + + - name: Run pre-build tests + shell: bash + env: + PYTHON_VERSION_SHORT: ${{ env.PYTHON_VERSION_SHORT }} + MOBILE_FORGE_TEST_PHASE: pre_build + run: python3 -m unittest discover -s android/tests -t android/tests -v + - working-directory: android shell: bash run: | @@ -96,7 +103,17 @@ jobs: if [ $version_int -lt 313 ]; then bash ./package-for-dart.sh install "$PYTHON_VERSION" armeabi-v7a fi - - uses: actions/upload-artifact@v4 + + - name: Run post-build tests + shell: bash + env: + PYTHON_VERSION_SHORT: ${{ env.PYTHON_VERSION_SHORT }} + MOBILE_FORGE_TEST_PHASE: post_build + MOBILE_FORGE_INSTALL_TREE: ${{ github.workspace }}/android/install + run: python3 -m unittest discover -s android/tests -t android/tests -v + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 with: name: python-android-${{ env.PYTHON_VERSION_SHORT }} path: android/dist/python-android-*.tar.gz @@ -105,24 +122,28 @@ jobs: build-linux: name: Build Python for Linux runs-on: ubuntu-latest - permissions: - contents: write steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + - name: Derive short Python version shell: bash run: | echo "PYTHON_VERSION_SHORT=$(echo "$PYTHON_VERSION" | cut -d. -f1,2)" >> "$GITHUB_ENV" - - uses: actions/setup-python@v6 + + - name: Setup Python + uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - - run: python --version + - working-directory: linux shell: bash run: | bash ./package-for-linux.sh x86_64 "_v2" bash ./package-for-linux.sh aarch64 "" - - uses: actions/upload-artifact@v4 + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 with: name: python-linux-${{ env.PYTHON_VERSION_SHORT }} path: linux/python-linux-dart-*.tar.gz @@ -131,31 +152,29 @@ jobs: build-windows: name: Build Python for Windows runs-on: windows-2022 - permissions: - contents: write steps: - uses: actions/checkout@v4 + - name: Derive short Python version shell: pwsh run: | $parts = "${{ env.PYTHON_VERSION }}".Split(".") "PYTHON_VERSION_SHORT=$($parts[0]).$($parts[1])" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Setup Python uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION_SHORT }} - - name: Show Python version - shell: pwsh - run: | - python --version - python -c "import sys; print(sys.executable)" + - name: Build CPython from sources and package for Dart shell: pwsh run: | .\windows\package-for-dart.ps1 ` -PythonVersion "${{ env.PYTHON_VERSION }}" ` -PythonVersionShort "${{ env.PYTHON_VERSION_SHORT }}" - - uses: actions/upload-artifact@v4 + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 with: name: python-windows-${{ env.PYTHON_VERSION_SHORT }} path: windows/python-windows-for-dart-*.zip @@ -169,6 +188,7 @@ jobs: - build-android - build-linux - build-windows + if: github.ref == 'refs/heads/main' permissions: contents: write steps: @@ -176,6 +196,7 @@ jobs: shell: bash run: | echo "PYTHON_VERSION_SHORT=$(echo "$PYTHON_VERSION" | cut -d. -f1,2)" >> "$GITHUB_ENV" + - name: Download all build artifacts uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml index a2320a1..89e6454 100644 --- a/.github/workflows/build-python.yml +++ b/.github/workflows/build-python.yml @@ -2,10 +2,14 @@ name: Build Python Packages on: push: - branches: - - '**' + pull_request: workflow_dispatch: +# Cancel in-flight runs when a newer event arrives for the same logical branch. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + jobs: build-matrix: name: Build Python ${{ matrix.python_version }} diff --git a/android/normalize_mobile_forge_install.py b/android/normalize_mobile_forge_install.py index bc0ca51..60f6cda 100644 --- a/android/normalize_mobile_forge_install.py +++ b/android/normalize_mobile_forge_install.py @@ -1,4 +1,27 @@ #!/usr/bin/env python3 +"""Post-`make install` normalization for python-build's Android install tree. + +Runs once on python-build's CI right after CPython's `make install` and +before the install tree is tarred up for downstream consumers +(mobile-forge, serious_python). Does two things: + + - Rewrites every `_sysconfigdata__*.py` under `prefix/lib/python*/` + to self-relocate at import time. The shipped sysconfigdata has + hard-coded python-build CI paths (`/home/runner/work/...`, + `/home/runner/ndk/...`); the injected + `_mobile_forge_relocate_sysconfig` function rewrites them on the + fly to the consumer's actual on-disk layout. See + `append_relocation_block` for the substitution model. + + - Replaces `prefix/lib/libpython3.so` (a stub for `-lpython3` abi3 + consumers) with a GNU ld linker script so consumer link commands + record the correct DT_NEEDED. See `replace_libpython_stub`. + +Invoked from `android/build.sh` at the end of the per-version build; +the contract point between python-build (what we ship) and +mobile-forge / serious_python (how they consume it). +""" + from __future__ import annotations import argparse @@ -7,40 +30,131 @@ def find_sysconfigdata(prefix: Path) -> list[Path]: + """Locate every `_sysconfigdata__*.py` under a Python install tree. + + CPython names sysconfigdata files by host triple (e.g. + `_sysconfigdata__linux_x86_64-linux-gnu.py`, `_sysconfigdata__linux_.py`), + so the trailing identifier varies per build configuration. We glob to cover all + of them and sort for a deterministic processing order. + + Args: + prefix: A Python install prefix (the dir containing `lib/`). + + Returns: + Sorted list of sysconfigdata file paths; may be empty. + """ return sorted((prefix / "lib").glob("python*/_sysconfigdata__*.py")) def replace_libpython_stub(prefix: Path) -> None: + """Replace `/lib/libpython3.so` with a GNU ld linker script. + + abi3-stable extension wheels link against `-lpython3`. The linker + resolves that to `libpython3.so` in the sysroot. On a vanilla + install `libpython3.so` is a symlink to `libpython3..so`, + and some linker pipelines record the symlink filename + (`libpython3.so`) into the wheel's `DT_NEEDED` instead of the + target's `SONAME`. When the wheel then ships to a device that only + carries `libpython3..so`, `dlopen` fails to resolve + `libpython3.so` and crashes. + + A linker script `INPUT ( -lpython3. )` makes the linker + resolve straight through to the versioned library at link time + without going via a filename that could leak into `DT_NEEDED`. + + Idempotent: if `libpython3.so` is already a symlink pointing at the + correct versioned target, leave it alone (consumers that work + against that symlink keep working); otherwise replace it. + + Args: + prefix: A Python install prefix (the dir containing `lib/`). + """ lib_dir = prefix / "lib" libpython = lib_dir / "libpython3.so" + # `libpython3.X.so` is the canonical versioned name; pick the first + # match (typically there's only one — the install-tree libpython). versioned = sorted(lib_dir.glob("libpython3.[0-9]*.so")) if not libpython.exists() or not versioned: + # Nothing to retarget. Tree was built without a libpython3.so + # stub, or the versioned library never landed — in either case + # there's no consumer-visible breakage to fix. return target = versioned[0].name + # Already a symlink pointing at the right versioned target — leave + # it alone so consumers that work against that symlink keep working. if libpython.is_symlink() and os.readlink(libpython) == target: return + # Replace whatever's at libpython3.so (stale symlink, regular file from a + # previous run, …) with a one-line ld linker script. `INPUT ( -lpython3.X )` + # tells ld to resolve through to libpython3.X.so without recording the bare + # `libpython3.so` name into the consumer's `DT_NEEDED`. libpython.unlink() - libpython.write_text(f"INPUT ( -l{target.removeprefix('lib').removesuffix('.so')} )\n") + libpython.write_text( + f"INPUT ( -l{target.removeprefix('lib').removesuffix('.so')} )\n" + ) -def append_relocation_block(path: Path, prefix: Path, ndk_toolchain: str | None) -> None: +def append_relocation_block( + path: Path, prefix: Path, ndk_toolchain: str | None +) -> None: + """Append a self-relocating block to a `_sysconfigdata__*.py` file. + + The appended block defines `_mobile_forge_relocate_sysconfig` and + calls it immediately, so any code that imports the sysconfigdata + module (CPython's `sysconfig` machinery, mobile-forge's crossenv, + setuptools/meson cross builds, etc.) sees `build_time_vars` already rewritten + for the consumer's filesystem — no explicit "please relocate" call required. + + Two substitution rules apply: + + 1. `_install_prefixes` → `_prefix`. Re-anchors python-build CI's + `$PREFIX` (`/home/runner/work/.../install/...`) to wherever + the consumer has the install tree on disk, derived from the + sysconfigdata file's own `__file__` via `parents[2]`. + + 2. `_build_ndk` → `_local_ndk`. Re-anchors python-build CI's NDK + toolchain path (e.g. `/home/runner/ndk/r27d/.../linux-x86_64`) + to whichever NDK the consumer can find locally — looked up + via `NDK_HOME`/`ANDROID_NDK_HOME`, then `~/ndk/`, then + the standard SDK roots. + + The two rules operate on disjoint substrings — no `_install_prefixes` + entry ever overlaps the NDK toolchain path — so the order between + them is irrelevant in correctness terms. + + Idempotent: the block carries a marker comment; re-applying it is a no-op. + + Args: + path: The `_sysconfigdata__*.py` file to mutate in place. + prefix: python-build CI's `$PREFIX`, baked verbatim into the rendered + block as `_build_prefix`. + ndk_toolchain: python-build CI's NDK toolchain path, baked verbatim + into the rendered block as `_build_ndk`. May be `None` on + non-Android sysconfigdata (the NDK substitution rule then no-ops). + """ marker = "# mobile-forge sysconfig relocation" text = path.read_text() if marker in text: + # Already applied (e.g. re-running build.sh after a partial failure). return block = f""" {marker} def _mobile_forge_relocate_sysconfig(): + # Runs once at sysconfigdata import time on the consumer host. Rewrites every path + # string baked into `build_time_vars` (CC, LDSHARED, LIBDIR, etc.) from python-build + # CI's filesystem layout to the consumer's. import os as _os from pathlib import Path as _Path + # __file__ = /lib/python/_sysconfigdata__*.py + # parents[2] = — what the consumer needs us to re-anchor build-time paths at. _prefix = str(_Path(__file__).resolve().parents[2]) _build_prefix = {str(prefix)!r} - _install_prefixes = (_build_prefix, "/usr/local") + _install_prefixes = (_build_prefix,) _build_ndk = {ndk_toolchain!r} def _candidate_ndk_homes(): @@ -104,20 +218,17 @@ def _local_toolchain(): return None _local_ndk = _local_toolchain() + + # Apply substitution rules for _key, _value in tuple(build_time_vars.items()): if not isinstance(_value, str): continue - # NDK substitution must run before install-prefix substitution: when - # the build-time NDK lives under one of `_install_prefixes` (e.g. the - # GitHub runner places NDK under `/usr/local/lib/android/sdk/ndk/...`), - # rewriting the prefix first would mangle the NDK string and leave - # nothing for the NDK rule to match. Swapping order keeps both rules - # independent: NDK fully resolves to the local toolchain, then any - # remaining install-prefix references get re-anchored. - if _build_ndk and _local_ndk: - _value = _value.replace(_build_ndk, _local_ndk) + # Rule 1 (install-prefix): re-anchor python-build $PREFIX to the consumer's install location. for _old_prefix in _install_prefixes: _value = _value.replace(_old_prefix, _prefix) + # Rule 2 (NDK): re-anchor the build-time toolchain to whichever NDK the consumer has locally. + if _build_ndk and _local_ndk: + _value = _value.replace(_build_ndk, _local_ndk) build_time_vars[_key] = _value @@ -128,9 +239,25 @@ def _local_toolchain(): def main() -> None: + """CLI entry point: normalize one install prefix end-to-end. + + Walks every sysconfigdata under `prefix/lib/python*/` and appends the + self-relocation block (idempotent), then retargets `libpython3.so` to a linker + script. Invoked from `android/build.sh` once per per-version build. + """ parser = argparse.ArgumentParser() - parser.add_argument("prefix", type=Path) - parser.add_argument("--ndk-toolchain") + parser.add_argument( + "prefix", + type=Path, + help="Install prefix to normalize (directory containing lib/).", + ) + parser.add_argument( + "--ndk-toolchain", + help="Build-time NDK toolchain path (for example: " + "~/ndk/r27d/toolchains/llvm/prebuilt/linux-x86_64). Baked into every " + "sysconfigdata's relocation block so the consumer-side substitution " + "knows what to replace. Omit on non-Android trees.", + ) args = parser.parse_args() prefix = args.prefix.resolve() diff --git a/android/tests/__init__.py b/android/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/android/tests/_testlib.py b/android/tests/_testlib.py new file mode 100644 index 0000000..119e8d3 --- /dev/null +++ b/android/tests/_testlib.py @@ -0,0 +1,81 @@ +"""Shared helpers for android/tests/. + +The python-build CI invokes `unittest discover -s android/tests` twice +per matrix shard (3.12.12, 3.13.12, 3.14.3) — once before `build-all.sh` +and once after — passing different env vars each time: + + Pre-build step: PYTHON_VERSION_SHORT= MOBILE_FORGE_TEST_PHASE=pre_build + Post-build step: PYTHON_VERSION_SHORT= MOBILE_FORGE_TEST_PHASE=post_build + MOBILE_FORGE_INSTALL_TREE=/android/install + +Tests opt INTO a phase via `@pre_build` or `@post_build`; tests opt into a specific +Python version via `@requires_python`. Decorators are stdlib `unittest.skipUnless` wrappers. + +Example: + + from _testlib import pre_build, post_build, requires_python + + @pre_build + class RelocatorRegressionTests(unittest.TestCase): + # Source-only logic; runs in the pre-build phase. + ... + + @post_build + class BuiltInstallTreeShape(unittest.TestCase): + # Inspects MOBILE_FORGE_INSTALL_TREE; runs in the post-build phase. + ... + + @post_build + @requires_python("3.12") + class TestLibpython312SonameInBuiltTree(unittest.TestCase): + # Composable; runs only on the 3.12 shard's post-build phase. + ... +""" + +import os +import unittest + +PYTHON_VERSION_SHORT: str = os.environ.get("PYTHON_VERSION_SHORT", "") + +# Whether `pre_build` or `post_build` +PHASE: str = os.environ.get("MOBILE_FORGE_TEST_PHASE", "") + +# Set by the workflow's post-build test step to the freshly-built install +# tree, so `@post_build` tests can inspect actual generated artifacts. +INSTALL_TREE: str = os.environ.get("MOBILE_FORGE_INSTALL_TREE", "") + + +def requires_python(*versions: str): + """Skip unless PYTHON_VERSION_SHORT is one of `versions`.""" + return unittest.skipUnless( + PYTHON_VERSION_SHORT in versions, + f"requires PYTHON_VERSION_SHORT in {versions} " + f"(got PYTHON_VERSION_SHORT={PYTHON_VERSION_SHORT or ''!r})", + ) + + +def pre_build(cls_or_func): + """Run only during the pre-build test phase. + + Pre-build tests exercise source-only logic and don't need a built install tree. + The workflow invokes this phase before the build step. + """ + return unittest.skipUnless( + PHASE in ("", "pre_build"), + f"requires test phase 'pre_build' " + f"(got MOBILE_FORGE_TEST_PHASE={PHASE or ''!r})", + )(cls_or_func) + + +def post_build(cls_or_func): + """Run only during the post-build test phase. + + Post-build tests inspect the actual generated `install/` tree — sysconfigdata shape, + dart-tarball ELF structure, etc. The workflow invokes this phase after build step + succeeds, with `MOBILE_FORGE_INSTALL_TREE` pointing at the install dir. + """ + return unittest.skipUnless( + PHASE in ("", "post_build"), + f"requires test phase 'post_build' " + f"(got MOBILE_FORGE_TEST_PHASE={PHASE or ''!r})", + )(cls_or_func) diff --git a/android/tests/test_built_install_tree.py b/android/tests/test_built_install_tree.py new file mode 100644 index 0000000..9da6369 --- /dev/null +++ b/android/tests/test_built_install_tree.py @@ -0,0 +1,147 @@ +"""Post-build tests against the actual generated `install/` tree. + +Complement to the source-only `test_normalize_mobile_forge_install.py` +unit tests: those exercise `append_relocation_block()` in isolation +against a stub sysconfigdata under a tempdir; the tests here read the +real sysconfigdata that `android/build-all.sh` just produced and check +the shipped artifact matches our expectations. + +Driven by `MOBILE_FORGE_INSTALL_TREE` from the workflow's post-build +test step. ABIs are version-dependent — 3.12 ships 4, 3.13+ ships 2 — +so tests iterate whatever's actually on disk rather than hardcoding. +""" + +import os +import re +import unittest +from pathlib import Path + +from _testlib import post_build, INSTALL_TREE, PYTHON_VERSION_SHORT + + +def _sysconfigdata_files(install_root: Path): + """Yield (abi, sysconfigdata path) for every ABI present under + `install_root/android//python-X.Y.Z/lib/pythonX.Y/_sysconfigdata__*.py`. + + The trailing identifier on the sysconfigdata filename varies by + toolchain: + - 3.12 (legacy `android-env.sh` path): `_sysconfigdata__linux_.py` + - 3.13+ (CPython's Android tooling): `_sysconfigdata__android_-linux-android.py` + We glob `_sysconfigdata__*.py` to cover both — same pattern that + `find_sysconfigdata()` uses in `normalize_mobile_forge_install.py`. + """ + android_root = install_root / "android" + if not android_root.is_dir(): + return + for abi_dir in sorted(android_root.iterdir()): + if not abi_dir.is_dir(): + continue + # python-X.Y.Z dir name is version-dependent; glob for it. + for py_dir in abi_dir.glob(f"python-{PYTHON_VERSION_SHORT}.*"): + lib_dir = py_dir / "lib" / f"python{PYTHON_VERSION_SHORT}" + for sd in sorted(lib_dir.glob("_sysconfigdata__*.py")): + yield abi_dir.name, sd + + +@post_build +class BuiltSysconfigdataShape(unittest.TestCase): + """For every ABI in the install tree, the shipped sysconfigdata must + have the relocator block applied and the substitution rules in the + shape the source code is meant to produce. + """ + + @classmethod + def setUpClass(cls): + # Resolved at import time by _testlib; bail with a clear message + # if the test was selected without the env var (unusual; the + # decorator already gates on it, but be explicit). + if not INSTALL_TREE: + raise unittest.SkipTest( + "MOBILE_FORGE_INSTALL_TREE not set — the workflow's post-build " + "test step is responsible for populating it." + ) + cls.install_root = Path(INSTALL_TREE) + cls.found = list(_sysconfigdata_files(cls.install_root)) + + def test_at_least_one_sysconfigdata_present(self): + """Sanity: build-all.sh produced something for at least one ABI. + + If this fails, every later test would skip trivially — surfacing + the empty-tree case as its own failure is clearer. + """ + self.assertGreater( + len(self.found), + 0, + f"No _sysconfigdata__*.py files found under {self.install_root}/android/" + f"/python-{PYTHON_VERSION_SHORT}.*/lib/python{PYTHON_VERSION_SHORT}/. " + f"Either build-all.sh produced nothing, or MOBILE_FORGE_INSTALL_TREE " + f"points at the wrong dir.", + ) + + def test_relocator_block_present(self): + """`build-all.sh` must invoke `normalize_mobile_forge_install.py` + so the relocator block lands in every shipped sysconfigdata. If + the call gets removed/skipped, this test catches it. + """ + for abi, sd in self.found: + with self.subTest(abi=abi): + self.assertIn( + "# mobile-forge sysconfig relocation", + sd.read_text(), + msg=( + f"Relocator block missing from {sd} — did build-all.sh " + f"skip the normalize_mobile_forge_install.py invocation?" + ), + ) + + def test_install_prefixes_no_usr_local(self): + """Regression guard for #8 at the *shipped artifact* level — complement to the + unit-test coverage in test_normalize_mobile_forge_install.py. + + If `_install_prefixes` ever ships with `"/usr/local"` in the tuple again, + mobile-forge CI on GitHub runners (NDK_HOME under /usr/local) will fail at + crossenv create — silently, with no captured stderr. This catches it before + the artifact escapes python-build's CI. + """ + for abi, sd in self.found: + with self.subTest(abi=abi): + m = re.search(r"_install_prefixes\s*=\s*\(([^)]*)\)", sd.read_text()) + self.assertIsNotNone( + m, msg=f"_install_prefixes tuple not found in {sd}" + ) + contents = m.group(1) + self.assertNotIn( + "/usr/local", + contents, + msg=( + f"/usr/local re-introduced into _install_prefixes in {sd} " + f"(value: {contents!r}). See fix/relocator-drops-usr-local-prefix " + f"for context." + ), + ) + + def test_build_ndk_baked(self): + """`_build_ndk` must be a non-empty path string. If `''` or `None` + ships, the NDK substitution rule no-ops on the consumer and + CC/CXX paths stay as Linux-CI-runner paths — the original 3.13+ + macOS bug that PR #8 added an extra `$toolchain` fallback to + fix. This guards against a regression of that fallback chain. + """ + for abi, sd in self.found: + with self.subTest(abi=abi): + m = re.search(r"_build_ndk\s*=\s*(.+)$", sd.read_text(), re.MULTILINE) + self.assertIsNotNone(m, msg=f"_build_ndk assignment not found in {sd}") + val = m.group(1).strip() + self.assertNotIn( + val, + ("''", '""', "None"), + msg=( + f"_build_ndk is empty in {sd} — android/build.sh failed " + f"to resolve $toolchain for Python {PYTHON_VERSION_SHORT}. " + f"Check the $toolchain detection fallback chain." + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/android/tests/test_normalize_mobile_forge_install.py b/android/tests/test_normalize_mobile_forge_install.py new file mode 100644 index 0000000..10ca254 --- /dev/null +++ b/android/tests/test_normalize_mobile_forge_install.py @@ -0,0 +1,335 @@ +"""Pre-build tests for android/normalize_mobile_forge_install.py — the +sysconfig relocator that gets appended to `_sysconfigdata__linux_.py` +so the build products can be re-anchored on a consumer host. + +Each test renders the relocation block into a stub sysconfigdata file +under a tempdir, builds a controlled fake-filesystem layout for the +consumer-side NDK lookup, exec's the rendered file as a Python module, +and asserts what `build_time_vars` contains afterwards. +""" + +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path + +# Make the sibling module importable (android/normalize_mobile_forge_install.py). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from normalize_mobile_forge_install import append_relocation_block # noqa: E402 + +from _testlib import pre_build # noqa: E402 + + +# --------------------------------------------------------------------------- +# Build-time path constants. Treated as opaque strings; the only requirement +# is that they contain the substrings the relocator scans for ("toolchains" +# inside _build_ndk, etc.). They do NOT need to exist on the test runner's +# filesystem — only the *consumer* side does. + +# 3.12 path: python-build/CI's android/build.sh sources android-env.sh, which +# installs the NDK at $HOME/ndk//, so the build-time NDK string ends +# up like this. +_BUILD_NDK_312 = "/home/runner/ndk/r27d/toolchains/llvm/prebuilt/linux-x86_64" + +# 3.13+ path: CPython's in-tree Android tooling auto-resolves the NDK from +# $ANDROID_HOME/ndk//, which on a GitHub runner lives under +# /usr/local/lib/android/sdk/ — the build-time NDK string then starts with /usr/local. +_BUILD_NDK_313 = ( + "/usr/local/lib/android/sdk/ndk/27.3.13750724/toolchains/llvm/prebuilt/linux-x86_64" +) + +_BUILD_PREFIX = "/home/runner/work/python-build/python-build/install/android/arm64-v8a/python-3.12.12" + + +# --------------------------------------------------------------------------- +# Fixture helpers + + +def _make_consumer_install(tmp: Path) -> Path: + """Build the layout the relocator expects under `parents[2]`. + + Returns the path the stub _sysconfigdata__linux_.py should be written to. The + relocator computes `_prefix = parents[2]` from that file's location, so + `/install/android//python-3.12.12` ends up as the consumer's install prefix. + """ + sd_dir = ( + tmp + / "install" + / "android" + / "arm64-v8a" + / "python-3.12.12" + / "lib" + / "python3.12" + ) + sd_dir.mkdir(parents=True) + return sd_dir / "_sysconfigdata__linux_.py" + + +def _make_consumer_ndk(ndk_home: Path, host_triple: str) -> Path: + """Build a fake `/toolchains/llvm/prebuilt//bin/` tree. + + Returns the toolchain dir — the path the relocator's `_local_toolchain()` will yield. + """ + toolchain = ndk_home / "toolchains" / "llvm" / "prebuilt" / host_triple + (toolchain / "bin").mkdir(parents=True) + return toolchain + + +def _render_and_exec( + sd_path: Path, + *, + build_prefix: str, + build_ndk: str, + baked_vars: dict, + env: dict, +) -> dict: + """Append the relocation block to a stub sysconfigdata file, exec it + under a controlled environment, and return the resulting build_time_vars. + """ + sd_path.write_text(f"build_time_vars = {baked_vars!r}\n") + append_relocation_block(sd_path, Path(build_prefix), build_ndk) + + orig_env = dict(os.environ) + os.environ.clear() + os.environ.update(env) + try: + spec = importlib.util.spec_from_file_location("_t_sd", sd_path) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return dict(mod.build_time_vars) + finally: + os.environ.clear() + os.environ.update(orig_env) + + +# --------------------------------------------------------------------------- +# Tests + + +@pre_build +class RelocatorRegressionTests(unittest.TestCase): + """The relocator runs at sysconfigdata import time on the consumer. + The regressions captured here all manifested as silent + `subprocess.CalledProcessError` from crossenv: the consumer reads a CC/CXX path + out of sysconfigdata, the path is mangled, the binary doesn't exist, crossenv exits + ENOENT, the wrapper raises with no captured stderr. + """ + + def test_mobile_forge_runner_ndk_under_usr_local(self): + """The PR #8 regression. When NDK_HOME points at a path containing + `/usr/local` (mobile-forge CI on GitHub runners), the relocator must produce a + CC value that contains `/usr/local` verbatim — NOT rewritten to the + consumer's python install prefix. + """ + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str).resolve() + sd_path = _make_consumer_install(tmp) + consumer_prefix = sd_path.parents[2] # .../python-3.12.12 + + # Mimic a GitHub runner exposing NDK at .../usr/local/lib/android/sdk/ndk/ + # The literal substring "/usr/local/" inside the path is what triggered the bug. + consumer_ndk = ( + tmp + / "runner_root" + / "usr" + / "local" + / "lib" + / "android" + / "sdk" + / "ndk" + / "27.3.13750724" + ) + toolchain = _make_consumer_ndk(consumer_ndk, "linux-x86_64") + + baked_cc = f"{_BUILD_NDK_312}/bin/aarch64-linux-android24-clang" + after = _render_and_exec( + sd_path, + build_prefix=_BUILD_PREFIX, + build_ndk=_BUILD_NDK_312, + baked_vars={"CC": baked_cc, "prefix": _BUILD_PREFIX}, + env={ + "PATH": os.environ.get("PATH", ""), + "HOME": str(tmp / "empty_home"), + "NDK_HOME": str(consumer_ndk), + }, + ) + + cc = after["CC"] + expected = f"{toolchain}/bin/aarch64-linux-android24-clang" + + self.assertEqual( + cc, + expected, + msg=( + "CC must resolve to the consumer's NDK toolchain. If this " + "fails, check that `_install_prefixes` doesn't contain " + "'/usr/local' — that entry would mangle the path the NDK " + "rule just substituted." + ), + ) + self.assertIn( + "/usr/local/", + cc, + msg="CC must retain the /usr/local component of the consumer NDK.", + ) + self.assertNotIn( + str(consumer_prefix), + cc, + msg=( + "CC must NOT contain the consumer's install prefix — if it " + "does, an install-prefix substitution has mangled a path " + "inside the consumer's NDK." + ), + ) + + def test_macos_dev_ndk_under_library(self): + """3.13+ on a macOS dev box. Build-time NDK lives under /usr/local + (Linux CI runner), consumer NDK lives at ~/Library/Android/sdk/ndk/. + CC should resolve to the darwin-x86_64 toolchain with no /usr/local + component left. + """ + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str).resolve() + sd_path = _make_consumer_install(tmp) + + fake_home = tmp / "fake_home" + macos_ndk = ( + fake_home / "Library" / "Android" / "sdk" / "ndk" / "27.3.13750724" + ) + toolchain = _make_consumer_ndk(macos_ndk, "darwin-x86_64") + + baked_cc = f"{_BUILD_NDK_313}/bin/aarch64-linux-android24-clang" + after = _render_and_exec( + sd_path, + build_prefix=_BUILD_PREFIX, + build_ndk=_BUILD_NDK_313, + baked_vars={"CC": baked_cc, "prefix": _BUILD_PREFIX}, + env={ + "PATH": os.environ.get("PATH", ""), + "HOME": str(fake_home), + # NDK_HOME deliberately unset — fallback walker must + # find ~/Library/Android/sdk/ndk/. + }, + ) + + cc = after["CC"] + self.assertEqual( + cc, + f"{toolchain}/bin/aarch64-linux-android24-clang", + msg="CC should resolve to the macOS darwin toolchain.", + ) + self.assertNotIn( + "/usr/local/", + cc, + msg="CC must not retain the build-time /usr/local NDK prefix.", + ) + self.assertIn( + "darwin-x86_64", + cc, + msg=( + "CC must use the consumer's darwin host triple, not the " + "build-time linux-x86_64." + ), + ) + + def test_no_consumer_ndk_keeps_build_path(self): + """If the relocator can't find any consumer NDK, it must leave CC + at the build-time path — not corrupt or blank it. + """ + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str).resolve() + sd_path = _make_consumer_install(tmp) + + baked_cc = f"{_BUILD_NDK_312}/bin/aarch64-linux-android24-clang" + after = _render_and_exec( + sd_path, + build_prefix=_BUILD_PREFIX, + build_ndk=_BUILD_NDK_312, + baked_vars={"CC": baked_cc, "prefix": _BUILD_PREFIX}, + env={ + "PATH": os.environ.get("PATH", ""), + "HOME": str(tmp / "empty_home"), + # NDK_HOME deliberately unset, and no fallback dirs exist. + }, + ) + + self.assertEqual(after["CC"], baked_cc) + + def test_build_prefix_substitution(self): + """A baked value referencing `_build_prefix` must be re-anchored + to the consumer's install prefix. + """ + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str).resolve() + sd_path = _make_consumer_install(tmp) + consumer_prefix = sd_path.parents[2] + + after = _render_and_exec( + sd_path, + build_prefix=_BUILD_PREFIX, + build_ndk=_BUILD_NDK_312, + baked_vars={"LIBDIR": f"{_BUILD_PREFIX}/lib"}, + env={ + "PATH": os.environ.get("PATH", ""), + "HOME": str(tmp / "empty_home"), + }, + ) + + self.assertEqual(after["LIBDIR"], f"{consumer_prefix}/lib") + + def test_compound_value_both_substitutions_apply_cleanly(self): + """A single baked value containing BOTH the build-time NDK and the + build-time install prefix must have both rewritten — without + either rule mangling the other's output. Guards against future + order-of-substitution regressions. + """ + with tempfile.TemporaryDirectory() as tmp_str: + tmp = Path(tmp_str).resolve() + sd_path = _make_consumer_install(tmp) + consumer_prefix = sd_path.parents[2] + + consumer_ndk = tmp / "consumer_ndk" + toolchain = _make_consumer_ndk(consumer_ndk, "linux-x86_64") + + baked_ldshared = ( + f"{_BUILD_NDK_312}/bin/aarch64-linux-android24-clang " + f"-shared -L{_BUILD_PREFIX}/lib" + ) + after = _render_and_exec( + sd_path, + build_prefix=_BUILD_PREFIX, + build_ndk=_BUILD_NDK_312, + baked_vars={"LDSHARED": baked_ldshared}, + env={ + "PATH": os.environ.get("PATH", ""), + "HOME": str(tmp / "empty_home"), + "NDK_HOME": str(consumer_ndk), + }, + ) + + ldshared = after["LDSHARED"] + self.assertIn( + f"{toolchain}/bin/aarch64-linux-android24-clang", + ldshared, + msg="NDK rule must rewrite the clang path.", + ) + self.assertIn( + f"-L{consumer_prefix}/lib", + ldshared, + msg="install-prefix rule must rewrite the -L path.", + ) + self.assertNotIn( + _BUILD_NDK_312, ldshared, msg="No build-time NDK path should remain." + ) + self.assertNotIn( + _BUILD_PREFIX, + ldshared, + msg="No build-time install prefix should remain.", + ) + + +if __name__ == "__main__": + unittest.main()