From f74d8cc8bb79409d8cbfa738483005ce32c28c3a Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 3 Jun 2026 15:20:51 +0200 Subject: [PATCH 1/5] Fix sysconfig relocator mangling NDK paths via overly-broad /usr/local prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_install_prefixes` was `(_build_prefix, "/usr/local")`. The second entry is overly broad and never legitimately matches anything in the Android cross-compile sysconfig outside of a build-time NDK path that happens to live under /usr/local (3.13+ CI). When the consumer also has its NDK under /usr/local — e.g. mobile-forge CI where the runner exports `NDK_HOME=/usr/local/lib/android/sdk/ndk/` — the substitution chain breaks: 1. NDK rule rewrites _build_ndk → _local_ndk. _local_ndk starts with /usr/local on the runner. 2. install-prefix rule rewrites "/usr/local" → _prefix (the consumer's python install dir). This mangles the path the NDK rule just correctly resolved. Result on the mobile-forge runner against the post-#8 v3.12 release: CC = '/home/runner/projects/python-build/android/install/android/ arm64-v8a/python-3.12.12/lib/android/sdk/ndk//toolchains/ llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang' That path doesn't exist. crossenv reads CC out of sysconfigdata and exits ENOENT — which is the silent `CalledProcessError` seen in 30+ Android wheel jobs in ndonkoHenri/mobile-forge run 26856516473. The fix: - Drop "/usr/local" from `_install_prefixes`. The dedicated `_build_ndk → _local_ndk` substitution already handles every NDK path correctly; the install-prefix entry adds no new coverage and breaks the case where the consumer's NDK happens to share that prefix. - Restore the original ordering of the two substitution rules (install-prefix first, then NDK). #8 swapped these as a workaround for the /usr/local entry's order-dependency. With /usr/local gone from the tuple, the two rules operate on disjoint substrings and the order between them is irrelevant — both orders produce identical output for every consumer environment we ship to (3.12/3.13/3.14 × Linux/macOS). 3.12 mobile-forge consumers, post-fix: CC = '/usr/local/lib/android/sdk/ndk//toolchains/llvm/prebuilt/ linux-x86_64/bin/aarch64-linux-android24-clang' 3.13+ macOS dev consumers (the case #8 was actually trying to fix), post-fix: CC = '~/Library/Android/sdk/ndk//toolchains/llvm/prebuilt/ darwin-x86_64/bin/aarch64-linux-android24-clang' Both correct, no mangling. Follow-up that should land in a separate PR: add a unit test that exercises the relocator with NDK_HOME under /usr/local so this exact regression can't slip in again unnoticed. --- android/normalize_mobile_forge_install.py | 155 ++++++++++++++++++++-- 1 file changed, 141 insertions(+), 14 deletions(-) diff --git a/android/normalize_mobile_forge_install.py b/android/normalize_mobile_forge_install.py index bc0ca51..c411698 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() From 6fde314da9a65d47466b69ce3a1b07a058e2f974 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 3 Jun 2026 16:02:32 +0200 Subject: [PATCH 2/5] Add android unit tests + two-phase CI test framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test scaffold under android/tests/ that scales without growing the workflow file. Tests opt INTO a CI phase via `@pre_build` or `@post_build` decorators from `_testlib`; the workflow has exactly two test steps (one per phase) and never has to learn about new tests as more get added. Test scoping API (android/tests/_testlib.py): @pre_build — run before build-all.sh (also runs locally when MOBILE_FORGE_TEST_PHASE is unset). @post_build — run after build-all.sh, with the freshly built install tree accessible via MOBILE_FORGE_INSTALL_TREE. @requires_python — gate on PYTHON_VERSION_SHORT (one or more values). All three are stdlib unittest.skipUnless wrappers — no pytest, no custom test loader. They compose freely (@post_build + @requires_python). Skip reasons are verbose by design: they tell future-you which env var was mis-set instead of silently no-running. Tests landed in this commit: android/tests/test_normalize_mobile_forge_install.py — @pre_build Five unit tests for `normalize_mobile_forge_install.append_relocation_block()`. The first one pins the #8 regression that the previous commit (63f9fe3) fixed: when consumer NDK_HOME contains /usr/local (mobile-forge runner case), the relocator must not mangle the path the NDK rule just resolved. Verified to FAIL against pre-fix code and PASS against the fix: AssertionError: '/lib/android/sdk/ndk//.../bin/clang' (broken) vs. '/.../usr/local/lib/android/sdk/ndk//.../bin/clang' (fixed) android/tests/test_built_install_tree.py — @post_build Four integration tests that read the freshly built install tree and assert structural invariants on every shipped sysconfigdata: - at least one sysconfigdata present (catches build-all.sh silently producing no artifacts) - the relocator block marker is present (catches build-all.sh skipping the normalize_mobile_forge_install.py invocation) - `_install_prefixes` does NOT contain /usr/local (regression guard for #8 at the shipped artifact level) - `_build_ndk` is not empty (regression guard for the $toolchain fallback chain PR #8 introduced for 3.13+) CI wiring (.github/workflows/build-python-version.yml): - new step before build-all.sh: `Run android tests (pre-build)` env: MOBILE_FORGE_TEST_PHASE=pre_build → catches source-only regressions in ~1s, before the ~12 min build. - new step after build-all.sh: `Run android tests (post-build)` env: MOBILE_FORGE_TEST_PHASE=post_build, MOBILE_FORGE_INSTALL_TREE=$GITHUB_WORKSPACE/android/install → verifies the shipped artifacts have the structure consumers (mobile-forge crossenv, flet build) depend on. Both steps are required to pass — no continue-on-error. Adding new test files later doesn't touch the workflow. --- .github/workflows/build-python-version.yml | 74 ++-- android/tests/__init__.py | 0 android/tests/_testlib.py | 81 +++++ android/tests/test_built_install_tree.py | 144 ++++++++ .../test_normalize_mobile_forge_install.py | 335 ++++++++++++++++++ 5 files changed, 606 insertions(+), 28 deletions(-) create mode 100644 android/tests/__init__.py create mode 100644 android/tests/_testlib.py create mode 100644 android/tests/test_built_install_tree.py create mode 100644 android/tests/test_normalize_mobile_forge_install.py diff --git a/.github/workflows/build-python-version.yml b/.github/workflows/build-python-version.yml index 3bb6f6c..bcb2619 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: write + 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,13 +188,12 @@ jobs: - build-android - build-linux - build-windows - permissions: - contents: write steps: - name: Derive short Python version 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/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..2b4b7ab --- /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 MOBILE_FORGE_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..ca7f67c --- /dev/null +++ b/android/tests/test_built_install_tree.py @@ -0,0 +1,144 @@ +"""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__linux_.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}.*"): + sd = ( + py_dir + / "lib" + / f"python{PYTHON_VERSION_SHORT}" + / "_sysconfigdata__linux_.py" + ) + if sd.is_file(): + 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__linux_.py files found under {self.install_root}/android/. " + 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() From 2a20416d11af003fd3575fbcb637ff6b8df7b7f7 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 3 Jun 2026 17:28:57 +0200 Subject: [PATCH 3/5] Add concurrency control and restrict publishing - Introduced concurrency to cancel in-progress runs on the same ref. - Limited workflow execution to the `main` branch. --- .github/workflows/build-python-version.yml | 1 + .github/workflows/build-python.yml | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-python-version.yml b/.github/workflows/build-python-version.yml index bcb2619..9d4d5bb 100644 --- a/.github/workflows/build-python-version.yml +++ b/.github/workflows/build-python-version.yml @@ -188,6 +188,7 @@ jobs: - build-android - build-linux - build-windows + if: github.ref == 'refs/heads/main' steps: - name: Derive short Python version shell: bash diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml index a2320a1..9f2dce0 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 commit lands on the same ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build-matrix: name: Build Python ${{ matrix.python_version }} From aa1c73f3d13d5c998d89b294661902c274bd5c05 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 3 Jun 2026 17:51:12 +0200 Subject: [PATCH 4/5] apply review patches --- .github/workflows/build-python-version.yml | 4 +++- .github/workflows/build-python.yml | 4 ++-- android/normalize_mobile_forge_install.py | 2 +- android/tests/_testlib.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-python-version.yml b/.github/workflows/build-python-version.yml index 9d4d5bb..977f4dd 100644 --- a/.github/workflows/build-python-version.yml +++ b/.github/workflows/build-python-version.yml @@ -23,7 +23,7 @@ env: PYTHON_DIST_RELEASE: 20260203 # https://github.com/astral-sh/python-build-standalone/releases permissions: - contents: write + contents: read jobs: build-darwin: @@ -189,6 +189,8 @@ jobs: - build-linux - build-windows if: github.ref == 'refs/heads/main' + permissions: + contents: write steps: - name: Derive short Python version shell: bash diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml index 9f2dce0..89e6454 100644 --- a/.github/workflows/build-python.yml +++ b/.github/workflows/build-python.yml @@ -5,9 +5,9 @@ on: pull_request: workflow_dispatch: -# Cancel in-flight runs when a newer commit lands on the same ref. +# Cancel in-flight runs when a newer event arrives for the same logical branch. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: diff --git a/android/normalize_mobile_forge_install.py b/android/normalize_mobile_forge_install.py index c411698..60f6cda 100644 --- a/android/normalize_mobile_forge_install.py +++ b/android/normalize_mobile_forge_install.py @@ -144,7 +144,7 @@ def append_relocation_block( {marker} def _mobile_forge_relocate_sysconfig(): - # Runs once at sysconfigdata import time on the consumer host. Rewrites every path + # 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 diff --git a/android/tests/_testlib.py b/android/tests/_testlib.py index 2b4b7ab..119e8d3 100644 --- a/android/tests/_testlib.py +++ b/android/tests/_testlib.py @@ -50,7 +50,7 @@ def requires_python(*versions: str): return unittest.skipUnless( PYTHON_VERSION_SHORT in versions, f"requires PYTHON_VERSION_SHORT in {versions} " - f"(got MOBILE_FORGE_PYTHON_VERSION_SHORT={PYTHON_VERSION_SHORT or ''!r})", + f"(got PYTHON_VERSION_SHORT={PYTHON_VERSION_SHORT or ''!r})", ) From ff248ee758140434c5315ceb6c967d0b79099f5e Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Wed, 3 Jun 2026 18:10:49 +0200 Subject: [PATCH 5/5] Fix post-build tests on 3.13+: glob _sysconfigdata__*.py, not just __linux_.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-build install-tree tests hardcoded the 3.12 sysconfigdata filename (`_sysconfigdata__linux_.py`), which doesn't exist on 3.13+. CPython's official Android tooling (used in the 3.13+ build path) names the file by host triple instead: 3.12 (legacy android-env.sh): _sysconfigdata__linux_.py 3.13+ (CPython's Android tooling): _sysconfigdata__android_-linux-android.py The relocator itself was unaffected — `find_sysconfigdata()` in `normalize_mobile_forge_install.py` already globs `_sysconfigdata__*.py` and applies the relocation block to whatever variant exists. It's purely the post-build tests in `test_built_install_tree.py` that needed the same glob. Symptom in CI: 3.13/3.14 matrix shards failed `test_at_least_one_sysconfigdata_present` (the safety net); the other three post-build tests iterated zero files and degenerated to silent vacuous passes. Fix: switch `_sysconfigdata_files()` to glob `_sysconfigdata__*.py` inside the per-version lib dir (same pattern the production-side `find_sysconfigdata()` uses). Updated the docstring + the `test_at_least_one_sysconfigdata_present` error message to drop the 3.12-only filename mention so future failures point at the right thing. Verified locally by synthesizing a 3.13 install tree with Android-named sysconfigdata files (`_sysconfigdata__android_arm64_v8a-linux-android.py` and `_sysconfigdata__android_x86_64-linux-android.py`) and running the post-build suite — all 4 tests pass. --- android/tests/test_built_install_tree.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/android/tests/test_built_install_tree.py b/android/tests/test_built_install_tree.py index ca7f67c..9da6369 100644 --- a/android/tests/test_built_install_tree.py +++ b/android/tests/test_built_install_tree.py @@ -21,7 +21,14 @@ 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__linux_.py`. + `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(): @@ -31,13 +38,8 @@ def _sysconfigdata_files(install_root: Path): 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}.*"): - sd = ( - py_dir - / "lib" - / f"python{PYTHON_VERSION_SHORT}" - / "_sysconfigdata__linux_.py" - ) - if sd.is_file(): + lib_dir = py_dir / "lib" / f"python{PYTHON_VERSION_SHORT}" + for sd in sorted(lib_dir.glob("_sysconfigdata__*.py")): yield abi_dir.name, sd @@ -70,7 +72,8 @@ def test_at_least_one_sysconfigdata_present(self): self.assertGreater( len(self.found), 0, - f"No _sysconfigdata__linux_.py files found under {self.install_root}/android/. " + 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.", )