scripts.build_ft_tree_sitter
Reference › scripts › build_ft_tree_sitter
Builds free-threaded wheels of the tree-sitter stack (SPEC-023 GIL-off doctrine), so the AST tooling can run on the free-threaded interpreter that build_ft_cpython produces. A human runs it; tests/scripts/test_build_ft_tree_sitter.py imports it.
Invocation: main with no options builds every package in PACKAGES in a temporary directory and lists the wheels. --install then force-reinstalls them with uv pip install and verifies the stack. --verify only checks the tree-sitter packages currently installed (verify). Without --install it prints how to install the wheels through the project's normal dependency sync.
Each Package describes one sdist, with sdist_name and wheel_path. build_package downloads the source distribution from PyPI (_pypi_sdist_url, _download), checking its sha256 (_sha256) against a pinned value, fetches the grammar headers where needed (_grammar_headers), and builds the wheel with a pinned system toolchain (_pin_system_toolchain, _build_env).
At a glance
| Source | scripts/build_ft_tree_sitter.py |
| Lines of code | 389 |
| Classes | 1 |
| Functions | 9 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | tests/scripts/test_build_ft_tree_sitter.py |
Class diagram
Dependency diagram
No internal imports and no importers.
API
Classes
Package (dataclass)
Decorators: dataclass · Line 105
| Attribute | Type | Default |
|---|---|---|
name |
str |
|
version |
str |
|
sdist_sha256 |
str |
|
fetch |
dict[str, tuple[str, str]] |
field(default_factory=dict) |
patches |
list[tuple[str, str, str]] |
field(default_factory=list) |
| Method | Signature | Summary |
|---|---|---|
sdist_name property |
sdist_name() -> str |
|
wheel_path property |
wheel_path() -> Path |
Functions
| Function | Signature | Summary |
|---|---|---|
_pin_system_toolchain |
_pin_system_toolchain(env: dict[str, str]) -> dict[str, str] |
worktree_toolchain.pin_system_toolchain, loaded by file path so it works however this script was imported (the same seam worktrees.py uses): CC/CXX come from the env/toolchain.env… |
_build_env |
_build_env() -> dict[str, str] |
Child environment for the C compile: the host shell minus the foreign toolchain contamination above, plus the DECLARED compiler: CC/CXX come from the env/toolchain.env declaration via… |
_grammar_headers |
_grammar_headers(repo: str, tag: str, src_dirs: list[str]) -> dict[str, tuple[str, str]] |
|
_sha256 |
_sha256(path: Path) -> str |
|
_download |
_download(url: str, dest: Path, sha256: str) -> None |
|
_pypi_sdist_url |
_pypi_sdist_url(pkg: Package) -> str |
|
build_package |
build_package(pkg: Package, work: Path) -> Path |
|
verify |
verify() -> int |
|
main |
main() -> int |
Source
#!/usr/bin/env python3
"""Build free-threaded tree-sitter wheels (SPEC-023 GIL-off doctrine).
Upstream tree-sitter packages cannot be installed on Python 3.14t as-is:
* ``tree-sitter`` (core) 0.26.0 **explicitly declares**
``PyUnstable_Module_SetGIL(module, Py_MOD_GIL_USED)`` at the end of
``PyInit__binding`` — importing it re-enables the GIL process-wide,
silently undoing SPEC-023. The patch flips it to ``Py_MOD_GIL_NOT_USED``
(the vendored-janus judgment: our usage is single-threaded parsing).
* Grammar sdists carry a ``bdist_wheel.get_tag`` override that retags every
wheel ``cp310-abi3`` even when the limited API was disabled for the
free-threaded build — the built wheel is then rejected as incompatible.
* Older grammar templates (cpp, typescript) hard-code ``py_limited_api=True``
+ a ``Py_LIMITED_API`` macro (the limited API does not exist on 3.14t) and
ship a single-phase ``PyInit__binding`` with no GIL declaration.
* Several sdists are INCOMPLETE — ``tree-sitter-python`` omits
``src/scanner.c`` (undefined ``..._external_scanner_create`` at import),
cpp/typescript omit ``src/tree_sitter/*.h`` and ``common/scanner.h``.
Upstream never notices because their PyPI wheels are CI-built from git.
Missing files are fetched from the immutable git TAG and sha256-verified.
This script makes the rebuild deterministic on a fresh clone:
1. download each PINNED sdist from PyPI and verify its sha256;
2. fetch sha256-PINNED missing files from the matching upstream git tag;
3. apply the source patches described above;
4. build each wheel with the project venv's python (``uv build --wheel
--no-build-isolation``, so the extension sees ``Py_GIL_DISABLED``) into
``vendor/wheels/``;
5. ``--verify`` imports every language, parses a snippet, and asserts the
GIL is still disabled.
``pyproject.toml`` points ``[tool.uv.sources]`` at the built wheels; the
wheels themselves are untracked (janus doctrine: binaries rebuild, never
commit). If ``uv sync`` fails with a missing-wheel path error, run:
python scripts/build_ft_tree_sitter.py && uv sync --dev
Bump the version/sha256 pins together when upgrading; drop a package's
patches once upstream ships free-threaded (cp314t) wheels for it.
"""
from __future__ import annotations
import argparse
import hashlib
import os
import subprocess
import sys
import tarfile
import tempfile
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
WHEEL_DIR = REPO_ROOT / "vendor" / "wheels"
VENV_PYTHON = REPO_ROOT / ".venv" / "bin" / "python3"
# Host-shell variables that leak a foreign toolchain's headers/libraries into
# the C compile. The Guix profile on the dev fleet exports these; with
# C_INCLUDE_PATH set, the compiler picks the Guix glibc headers and dies on
# the GCC-only _Float64 typedefs in bits/floatn.h (measured: Clang 19 vs
# /home/*/.guix-profile/include/math.h), and LIBRARY_PATH misdirects the
# linker the same way. Same hazard and remedy as scripts/build_janus_ft.py.
_BUILD_ENV_STRIP = ("C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "CPATH", "LIBRARY_PATH")
def _pin_system_toolchain(env: dict[str, str]) -> dict[str, str]:
"""``worktree_toolchain.pin_system_toolchain``, loaded by file path so it
works however this script was imported (the same seam worktrees.py uses):
CC/CXX come from the ``env/toolchain.env`` declaration, unconditionally —
never from PATH — and a missing, bare-named or non-executable declaration
raises ``ToolchainDeclarationError``, so the build is refused."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"build_ft_tree_sitter_toolchain",
Path(__file__).resolve().parent / "worktree_toolchain.py",
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.pin_system_toolchain(env)
def _build_env() -> dict[str, str]:
"""Child environment for the C compile: the host shell minus the foreign
toolchain contamination above, plus the DECLARED compiler: CC/CXX come
from the ``env/toolchain.env`` declaration via
``worktree_toolchain.pin_system_toolchain``, unconditionally, never from
PATH (a Guix gcc on PATH carries glibc headers with no Linux kernel
headers; measured 2026-10-05: `linux/limits.h: No such file or
directory`). A missing, bare-named or non-executable declaration raises
``ToolchainDeclarationError`` and the build is refused."""
env = {k: v for k, v in os.environ.items() if k not in _BUILD_ENV_STRIP}
return _pin_system_toolchain(env)
RAW = "https://raw.githubusercontent.com/tree-sitter"
@dataclass
class Package:
name: str
version: str
sdist_sha256: str
# repo-relative extra files missing from the sdist: {dest: (url, sha256)}
fetch: dict[str, tuple[str, str]] = field(default_factory=dict)
# (path, old, new) exact-string source patches; every one must apply
patches: list[tuple[str, str, str]] = field(default_factory=list)
@property
def sdist_name(self) -> str:
return f"{self.name.replace('-', '_')}-{self.version}"
@property
def wheel_path(self) -> Path:
return WHEEL_DIR / (f"{self.sdist_name}-cp314-cp314t-linux_x86_64.whl")
# The abi3 retag override shared by every grammar setup.py template.
_RETAG_OLD = ' if python.startswith("cp"):'
_RETAG_NEW = (
' if python.startswith("cp")' ' and not get_config_var("Py_GIL_DISABLED"):'
)
# Single-phase PyInit in the older grammar template (cpp / typescript).
_INIT_OLD = (
"PyMODINIT_FUNC PyInit__binding(void) {\n"
" return PyModule_Create(&module);\n"
"}"
)
_INIT_NEW = (
"PyMODINIT_FUNC PyInit__binding(void) {\n"
" PyObject *m = PyModule_Create(&module);\n"
"#ifdef Py_GIL_DISABLED\n"
" if (m != NULL) {\n"
" PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);\n"
" }\n"
"#endif\n"
" return m;\n"
"}"
)
_HDR = { # shared src/tree_sitter headers (identical across both repos/tags)
"parser.h": "a1f6ef161fbaf48a0e10fca90ef5290a062462b307b3898aa562993853b9f80a",
"alloc.h": "b29c1c9fb7cc82f58c84b376df1297d6e2737a1d655fd356db0859e3c29c2fea",
"array.h": "4ff743903dc46f5db6aa54f31c6b4d160a8a9779e5b2ab1ee59ae7ebcd850ea1",
}
def _grammar_headers(
repo: str, tag: str, src_dirs: list[str]
) -> dict[str, tuple[str, str]]:
out: dict[str, tuple[str, str]] = {}
for d in src_dirs:
for h, digest in _HDR.items():
out[f"{d}/tree_sitter/{h}"] = (
f"{RAW}/{repo}/{tag}/{d}/tree_sitter/{h}",
digest,
)
return out
PACKAGES = [
Package(
name="tree-sitter",
version="0.26.0",
sdist_sha256="b40c219edccc4564530c96f8f1556f6202b37cda964d1cbd7bd2b7e68b40a245",
patches=[
(
"tree_sitter/binding/module.c",
"PyUnstable_Module_SetGIL(module, Py_MOD_GIL_USED);",
"PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED);",
),
],
),
Package(
name="tree-sitter-python",
version="0.25.0",
sdist_sha256="b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac",
fetch={
"src/scanner.c": (
f"{RAW}/tree-sitter-python/v0.25.0/src/scanner.c",
"6db82134ac2d4c90a1a1475487a625cface02662ebda9b7478cad9c7147e9afe",
),
},
patches=[("setup.py", _RETAG_OLD, _RETAG_NEW)],
),
Package(
name="tree-sitter-c",
version="0.24.2",
sdist_sha256="1628584df0299b5a340aa63f8e67b6c97c91517f52fa7e7a4c557e40adb330a9",
# 0.24.2 already has the FT-aware limited-api logic, the fixed retag
# guard, the Py_mod_gil slot, and no external scanner — build as-is.
),
Package(
name="tree-sitter-cpp",
version="0.23.4",
sdist_sha256="6a59c4cebb1ad1dc2e8d586cf8a72b39d21b8108b7b139d089719e81a339e41d",
fetch=_grammar_headers("tree-sitter-cpp", "v0.23.4", ["src"]),
patches=[
(
"setup.py",
"from setuptools import",
"from sysconfig import get_config_var\nfrom setuptools import",
),
("setup.py", _RETAG_OLD, _RETAG_NEW),
(
"setup.py",
"py_limited_api=True,",
'py_limited_api=not get_config_var("Py_GIL_DISABLED"),',
),
("setup.py", ' ("Py_LIMITED_API", "0x03090000"),\n', ""),
("bindings/python/tree_sitter_cpp/binding.c", _INIT_OLD, _INIT_NEW),
],
),
Package(
name="tree-sitter-typescript",
version="0.23.2",
sdist_sha256="7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d",
fetch={
**_grammar_headers(
"tree-sitter-typescript",
"v0.23.2",
["typescript/src", "tsx/src"],
),
"common/scanner.h": (
f"{RAW}/tree-sitter-typescript/v0.23.2/common/scanner.h",
"da66ef2bd14a3f7ea743e25ba068c6c9aae2c3509db200ff80c4a0e6116e564c",
),
},
patches=[
(
"setup.py",
"from setuptools import",
"from sysconfig import get_config_var\nfrom setuptools import",
),
("setup.py", _RETAG_OLD, _RETAG_NEW),
(
"setup.py",
"py_limited_api=True,",
'py_limited_api=not get_config_var("Py_GIL_DISABLED"),',
),
("setup.py", ' ("Py_LIMITED_API", "0x03090000"),\n', ""),
(
"bindings/python/tree_sitter_typescript/binding.c",
_INIT_OLD,
_INIT_NEW,
),
],
),
]
VERIFY_SNIPPET = """
import sys
import tree_sitter_c, tree_sitter_cpp, tree_sitter_python, tree_sitter_typescript
from tree_sitter import Language, Parser
cases = [
("python", tree_sitter_python.language(), b"import os"),
("c", tree_sitter_c.language(), b"int main(void) { return 0; }"),
("cpp", tree_sitter_cpp.language(), b"class A { int x; };"),
("typescript", tree_sitter_typescript.language_typescript(), b"const x: number = 1;"),
("tsx", tree_sitter_typescript.language_tsx(), b"const el = <div>hi</div>;"),
]
for name, lang, src in cases:
tree = Parser(Language(lang)).parse(src)
assert not tree.root_node.has_error, f"{name}: parse FAILED"
print(f"{name}: parse ok ({tree.root_node.type})")
if sys._is_gil_enabled():
raise SystemExit("FAIL: the GIL is enabled — a wheel lost its patch")
print("GIL: off — all five languages free-threaded clean")
"""
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _download(url: str, dest: Path, sha256: str) -> None:
with urllib.request.urlopen(url, timeout=120) as resp:
dest.write_bytes(resp.read())
got = _sha256(dest)
if got != sha256:
raise SystemExit(f"sha256 mismatch for {url}: got {got}, want {sha256}")
def _pypi_sdist_url(pkg: Package) -> str:
import json
url = f"https://pypi.org/pypi/{pkg.name}/{pkg.version}/json"
with urllib.request.urlopen(url, timeout=60) as resp:
data = json.load(resp)
for u in data["urls"]:
if u["packagetype"] == "sdist":
return u["url"]
raise SystemExit(f"no sdist listed on PyPI for {pkg.name}=={pkg.version}")
def build_package(pkg: Package, work: Path) -> Path:
tarball = work / f"{pkg.sdist_name}.tar.gz"
print(f"[{pkg.name}] downloading sdist {pkg.version}")
_download(_pypi_sdist_url(pkg), tarball, pkg.sdist_sha256)
with tarfile.open(tarball) as tf:
tf.extractall(work, filter="data")
src = work / pkg.sdist_name
for rel, (url, digest) in pkg.fetch.items():
dest = src / rel
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"[{pkg.name}] fetching missing {rel} from upstream tag")
_download(url, dest, digest)
for rel, old, new in pkg.patches:
target = src / rel
text = target.read_text()
if old not in text:
raise SystemExit(f"[{pkg.name}] patch pattern not found in {rel}")
target.write_text(text.replace(old, new, 1))
print(f"[{pkg.name}] patched {rel}")
WHEEL_DIR.mkdir(parents=True, exist_ok=True)
print(f"[{pkg.name}] building wheel (no build isolation, 3.14t)")
subprocess.run(
[
"uv",
"build",
"--wheel",
"--no-build-isolation",
"-o",
str(WHEEL_DIR),
str(src),
],
check=True,
env=_build_env(),
)
if not pkg.wheel_path.exists():
raise SystemExit(f"[{pkg.name}] expected wheel missing: {pkg.wheel_path.name}")
return pkg.wheel_path
def verify() -> int:
proc = subprocess.run([str(VENV_PYTHON), "-c", VERIFY_SNIPPET])
return proc.returncode
def main() -> int:
parser = argparse.ArgumentParser(
description="Build free-threaded tree-sitter wheels (SPEC-023)."
)
parser.add_argument(
"--verify",
action="store_true",
help="only verify the currently installed tree-sitter stack",
)
parser.add_argument(
"--install",
action="store_true",
help="uv pip install the built wheels after building",
)
args = parser.parse_args()
if args.verify:
return verify()
with tempfile.TemporaryDirectory(prefix="ft-tree-sitter-") as tmp:
wheels = [build_package(pkg, Path(tmp)) for pkg in PACKAGES]
print("\nBuilt wheels:")
for w in wheels:
print(f" {w.relative_to(REPO_ROOT)}")
if args.install:
subprocess.run(
["uv", "pip", "install", "--force-reinstall"] + [str(w) for w in wheels],
check=True,
)
return verify()
print(
"\nInstall + verify: uv sync --dev (wheels are wired in pyproject)"
"\nor: python scripts/build_ft_tree_sitter.py --install"
)
return 0
if __name__ == "__main__":
sys.exit(main())