scripts.build_janus_ft
Reference › scripts › build_janus_ft
Builds a versioned release wheel and sdist for the patched free-threaded janus-swi package, the bridge between Python and SWI-Prolog. The patched source lives in scripts/_janus_patched, and the output goes to the dist-janus/ directory. A human runs it; tests/scripts/test_build_janus_ft.py imports it.
Invocation: main takes --local-version, a PEP 440 local version segment appended to the upstream version (a default is built in). build checks that it is running on a free-threaded interpreter (_check_free_threaded_interpreter), reads the upstream version (_read_upstream_version), copies the source to a temporary directory with the new version applied (_prepare_versioned_source) and builds with a pinned system toolchain (_build_env, _pin_system_toolchain). It prints the artifacts and the next step, which is to install a wheel into a target virtual environment with scripts/install_janus_ft.sh.
At a glance
| Source | scripts/build_janus_ft.py |
| Lines of code | 250 |
| Classes | 0 |
| Functions | 8 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | tests/scripts/test_build_janus_ft.py |
Class diagram
No classes; see Functions.
Dependency diagram
No internal imports and no importers.
API
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 warning demotion janus.c needs. |
_run |
_run(cmd: list[str], cwd: Path | None = None, env: dict[str, str] | None = None) -> None |
|
_read_upstream_version |
_read_upstream_version() -> str |
|
_prepare_versioned_source |
_prepare_versioned_source(tmp: Path, local_segment: str) -> tuple[Path, str] |
Copy the vendored source into tmp with a local version segment. |
_check_free_threaded_interpreter |
_check_free_threaded_interpreter() -> None |
|
build |
build(local_segment: str) -> list[Path] |
|
main |
main() -> int |
Source
#!/usr/bin/env python3
"""Build a versioned release wheel + sdist for the patched free-threaded janus-swi.
``scripts/_janus_patched`` vendors a thread-safe fork of janus-swi (the
SWI-Prolog <-> Python bridge) that keeps CPython's GIL genuinely disabled
under Python 3.14t (see ``docs/free-threaded-prolog.md`` for the full patch
inventory and root-cause analysis). During normal development it is built
in place by ``uv sync`` via ``[tool.uv.sources] janus-swi = { path =
"scripts/_janus_patched" }`` + ``no-build-isolation-package = ["janus-swi"]``
(pyproject.toml) — that always produces version ``1.5.3`` (the version
baked into the vendored ``setup.py``/``VERSION``), indistinguishable from a
hypothetical unpatched 1.5.3.
This script instead produces a RELEASE artifact for distribution off this
repo (a fresh machine, a container image, another project): a wheel + sdist
tagged with a local version segment (PEP 440, e.g. ``1.5.3+orchestrata.1``)
so it can never be mistaken for stock janus-swi. It does this WITHOUT
touching the vendored source tree — it copies ``scripts/_janus_patched``
into a scratch directory, rewrites the version string in the copy's
``setup.py`` (the vendored pyproject.toml declares ``dynamic = ["version"]``
and setup.py supplies it directly, no setuptools-scm involved — see the
"Version" note in docs/free-threaded-prolog.md), and builds from the copy
with ``uv build --no-build-isolation`` against the project's free-threaded
3.14t interpreter so the produced wheel carries the ``cp314t`` ABI tag and
was actually compiled with ``Py_GIL_DISABLED`` defined.
Usage::
python scripts/build_janus_ft.py # build wheel + sdist
python scripts/build_janus_ft.py --local-version orchestrata.2
Bump ``LOCAL_VERSION_SEGMENT`` (or pass ``--local-version``) whenever the
vendored patch changes; the artifact filename and ``janus_swi.dist-info``
metadata both carry it. Mirrors the vendored-FT-grpcio playbook
(``scripts/build_ft_grpcio.py``): binaries are built, sha256'd, and never
committed (``dist-janus/`` is gitignored) — a fresh clone regenerates them
on demand.
"""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
VENDORED_DIR = REPO_ROOT / "scripts" / "_janus_patched"
DIST_DIR = REPO_ROOT / "dist-janus"
VENV_PYTHON = REPO_ROOT / ".venv" / "bin" / "python3.14t"
# Bump this when the vendored patch (scripts/_janus_patched) changes so the
# artifact filename/version always distinguishes releases. Not derived
# automatically — the vendored source has no notion of "patch generation".
LOCAL_VERSION_SEGMENT = "orchestrata.1"
# Directories/files present in the working tree (build cruft, VCS-ignored)
# that must not be copied into the release source tree.
_COPY_IGNORE = shutil.ignore_patterns(
"build", "__pycache__", "*.egg-info", "*.so", "dist", ".git"
)
# 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/stdlib.h), and LIBRARY_PATH misdirects the
# linker the same way. The vendored setup.py passes swipl's own include/lib
# dirs explicitly, so scrubbing these loses nothing.
_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_janus_ft_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 warning demotion janus.c needs.
janus.c registers foreign predicates through old-style ``foreign_t``
pointers passed to ``PL_register_foreign_in_module``; GCC >= 14 makes
``-Wincompatible-pointer-types`` a hard error for that (see
scripts/_janus_patched/BUILDING.md), so demote it for whatever ``cc``
the build lands on (harmless where the warning never fires).
"""
env = {k: v for k, v in os.environ.items() if k not in _BUILD_ENV_STRIP}
# Scrubbing include paths is not enough when the COMPILER itself is the
# foreign one: with ~/.guix-profile/bin first on PATH, `cc` is Guix's gcc,
# whose glibc headers carry no Linux kernel headers (measured 2026-10-05:
# `bits/local_lim.h:38: fatal error: linux/limits.h: No such file`). The
# compiler comes from the env/toolchain.env declaration via
# worktree_toolchain.pin_system_toolchain, unconditionally (never from
# PATH); a missing, bare-named or non-executable declaration raises
# ToolchainDeclarationError and the build is refused.
env = _pin_system_toolchain(env)
demote = "-Wno-error=incompatible-pointer-types"
env["CFLAGS"] = f"{env.get('CFLAGS', '')} {demote}".strip()
return env
def _run(
cmd: list[str],
cwd: Path | None = None,
env: dict[str, str] | None = None,
) -> None:
print(f"$ {' '.join(cmd)}")
subprocess.run(cmd, check=True, cwd=cwd, env=env)
def _read_upstream_version() -> str:
version = (VENDORED_DIR / "VERSION").read_text().strip()
if not version:
raise SystemExit(f"empty VERSION file: {VENDORED_DIR / 'VERSION'}")
return version
def _prepare_versioned_source(tmp: Path, local_segment: str) -> tuple[Path, str]:
"""Copy the vendored source into ``tmp`` with a local version segment.
Returns (source_dir, full_version). The original vendored tree under
scripts/_janus_patched is never written to — only the scratch copy is.
"""
base_version = _read_upstream_version()
version = f"{base_version}+{local_segment}"
src_copy = tmp / "_janus_patched"
shutil.copytree(VENDORED_DIR, src_copy, ignore=_COPY_IGNORE)
setup_py = src_copy / "setup.py"
text = setup_py.read_text()
marker = f"version='{base_version}'"
if marker not in text:
raise SystemExit(
f"expected {marker!r} in {setup_py} (vendored setup.py drifted — "
"update this script's version-rewrite logic to match)"
)
setup_py.write_text(text.replace(marker, f"version='{version}'"))
(src_copy / "VERSION").write_text(version + "\n")
return src_copy, version
def _check_free_threaded_interpreter() -> None:
if not VENV_PYTHON.exists():
raise SystemExit(
f"{VENV_PYTHON} not found — create the project venv first "
"(uv python install 3.14t && uv sync --dev)"
)
result = subprocess.run(
[
str(VENV_PYTHON),
"-c",
"import sys; print("
"hasattr(sys, '_is_gil_enabled') and not sys._is_gil_enabled()"
")",
],
capture_output=True,
text=True,
check=True,
)
if result.stdout.strip() != "True":
raise SystemExit(
f"{VENV_PYTHON} is not a free-threaded (3.14t) interpreter — "
"janus-swi MUST be compiled against Py_GIL_DISABLED headers to "
"produce a cp314t wheel (see docs/free-threaded-prolog.md)"
)
def build(local_segment: str) -> list[Path]:
_check_free_threaded_interpreter()
if not VENDORED_DIR.exists():
raise SystemExit(f"vendored source missing: {VENDORED_DIR}")
DIST_DIR.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="janus-ft-build-") as tmp:
tmp_path = Path(tmp)
src_copy, version = _prepare_versioned_source(tmp_path, local_segment)
_run(
[
"uv",
"build",
"--sdist",
"--wheel",
"--no-build-isolation",
"--python",
str(VENV_PYTHON),
"--out-dir",
str(DIST_DIR),
str(src_copy),
],
env=_build_env(),
)
artifacts = sorted(DIST_DIR.glob(f"janus_swi-{version}*"))
if not artifacts:
raise SystemExit(f"build finished but no artifacts found in {DIST_DIR}")
for artifact in artifacts:
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
sha_file = artifact.with_suffix(artifact.suffix + ".sha256")
sha_file.write_text(f"{digest} {artifact.name}\n")
print(
f"Built: {artifact.name} ({artifact.stat().st_size} bytes) sha256={digest}"
)
return artifacts
def main() -> int:
parser = argparse.ArgumentParser(
description="Build a versioned wheel+sdist release of the patched "
"free-threaded janus-swi (scripts/_janus_patched) into dist-janus/."
)
parser.add_argument(
"--local-version",
default=LOCAL_VERSION_SEGMENT,
help=f"PEP 440 local version segment (default: {LOCAL_VERSION_SEGMENT})",
)
args = parser.parse_args()
artifacts = build(args.local_version)
print(f"\n{len(artifacts)} artifact(s) in {DIST_DIR.relative_to(REPO_ROOT)}/")
print(
"Next: scripts/install_janus_ft.sh --wheel "
f"{DIST_DIR.relative_to(REPO_ROOT)}/<the .whl above> --venv <target>"
)
return 0
if __name__ == "__main__":
sys.exit(main())