Skip to content

Commit 947346b

Browse files
committed
tui-py: build the wheel in Nix instead of maturin
Package the ix-tui wheel from the shared cargo-unit workspace graph: the PyO3 cdylib is already built as a workspace library, so default.nix just sanitizes it (strip rpath + nixpkgs refs) and wheel/mkwheel.py zips it with the Python source into a PEP 427 wheel. No maturin, no PEP 517 backend; pyproject.toml is metadata only. `nix build .#tui-py` emits ix_tui-<version>-cp311-abi3-manylinux_2_34_<arch>.whl. Linux-only, matching ix's native SDK wheels: a PyO3 extension cdylib links only where a shared object may carry undefined symbols. macOS needs `-undefined dynamic_lookup`, which the shared graph does not thread through, so the package set restricts tui-py to Linux. Closes the maturin TODO (#262).
1 parent 547f03a commit 947346b

5 files changed

Lines changed: 221 additions & 22 deletions

File tree

packages/tui-py/README.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,23 @@ PyPI distribution name: `ix-tui`. Import name: `tui`.
1414

1515
## Build
1616

17-
For now the wheel is built with [maturin]. From this directory:
17+
The wheel is built by Nix, not maturin. The PyO3 cdylib comes out of the shared
18+
`cargo-unit` workspace graph (the same one the rest of the repo's Rust builds
19+
from) and [`wheel/mkwheel.py`](wheel/mkwheel.py) packages it with the Python
20+
source into a PEP 427 wheel. There is no PEP 517 backend; `pip install .` is not
21+
a supported path.
1822

1923
```sh
20-
pip install maturin
21-
maturin develop --release
24+
nix build .#tui-py # writes ix_tui-<version>-cp311-abi3-manylinux_2_34_<arch>.whl
2225
```
2326

24-
Or to produce a wheel:
25-
26-
```sh
27-
maturin build --release
28-
```
29-
30-
The long-term path is to assemble the wheel through Nix + `cargo-unit`
31-
instead of maturin; tracked by
32-
[indexable-inc/index#262](https://github.com/indexable-inc/index/issues/262).
27+
The wheel is Linux-only, like ix's native SDK wheels: a PyO3 extension cdylib
28+
links cleanly only where a shared object may carry undefined symbols (Linux);
29+
macOS needs `-undefined dynamic_lookup`, which the shared cargo-unit graph does
30+
not thread through. From a macOS checkout, build it on a Linux builder with
31+
`nix build .#packages.x86_64-linux.tui-py`. The extension is abi3
32+
(`pyo3/abi3-py311`), so one wheel loads on CPython 3.11+; `pip install` it from
33+
`result/`.
3334

3435
## Quick start
3536

@@ -237,5 +238,4 @@ to tune the viewport sampling interval in seconds.
237238
| `serve` | Start the web dashboard for every live `Tui`. |
238239
| `Dashboard` | Handle to a running dashboard: `url`, `open`, `stop`. |
239240

240-
[maturin]: https://www.maturin.rs/
241241
[pyo3-async-runtimes]: https://docs.rs/pyo3-async-runtimes/

packages/tui-py/default.nix

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{
2+
ix,
3+
lib,
4+
pkgs ? ix.pkgs,
5+
}:
6+
let
7+
pyproject = lib.importTOML ./pyproject.toml;
8+
inherit (pyproject.project) version;
9+
10+
# The PyO3 cdylib is already built by the shared workspace unit graph (the
11+
# same one mcp selects its binary from), so the wheel is just packaging: no
12+
# maturin, no second compile.
13+
library = ix.rustWorkspace.units.libraries.tui_py;
14+
15+
# Linux-only: the package set restricts tui-py to Linux (see package.nix), so
16+
# only the manylinux tags are reachable here.
17+
platformTag =
18+
{
19+
x86_64-linux = "manylinux_2_34_x86_64";
20+
aarch64-linux = "manylinux_2_34_aarch64";
21+
}
22+
.${pkgs.stdenv.hostPlatform.system}
23+
or (throw "tui-py: wheel is Linux-only, got ${pkgs.stdenv.hostPlatform.system}");
24+
25+
pythonSource = builtins.path {
26+
name = "tui-py-python-source";
27+
path = ./python;
28+
};
29+
in
30+
pkgs.runCommand "ix-tui-wheel"
31+
{
32+
strictDeps = true;
33+
nativeBuildInputs = [
34+
pkgs.coreutils
35+
pkgs.python3
36+
pkgs.patchelf
37+
pkgs.removeReferencesTo
38+
];
39+
passthru = { inherit library; };
40+
meta.description = "ix-tui Python wheel (PyO3 bindings for the tui PTY manager)";
41+
}
42+
''
43+
set -euo pipefail
44+
45+
cdylib=""
46+
for candidate in \
47+
${library}/lib/libtui_py.so \
48+
${library}/lib/libtui_py-*.so \
49+
${library}/lib/libtui_py.dylib \
50+
${library}/lib/libtui_py-*.dylib
51+
do
52+
if [ -f "$candidate" ]; then
53+
cdylib="$candidate"
54+
break
55+
fi
56+
done
57+
if [ -z "$cdylib" ]; then
58+
echo "tui-py: no cdylib under ${library}/lib" >&2
59+
ls -la ${library}/lib >&2 || true
60+
exit 1
61+
fi
62+
63+
sanitized="$TMPDIR/$(basename "$cdylib")"
64+
cp "$cdylib" "$sanitized"
65+
chmod u+w "$sanitized"
66+
67+
# Strip the build-time rpath and nixpkgs toolchain references so the wheel
68+
# is not pinned to this store path.
69+
if patchelf --print-rpath "$sanitized" >/dev/null 2>&1; then
70+
patchelf --remove-rpath "$sanitized"
71+
fi
72+
remove-references-to \
73+
-t ${pkgs.glibc} \
74+
-t ${pkgs.stdenv.cc.cc.lib} \
75+
"$sanitized"
76+
77+
mkdir -p "$out"
78+
python3 ${./wheel/mkwheel.py} \
79+
--cdylib "$sanitized" \
80+
--python-src ${pythonSource} \
81+
--version ${version} \
82+
--platform-tag ${platformTag} \
83+
--out "$out"
84+
''

packages/tui-py/package.nix

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
11
{
22
id = "tui-py";
33
inRustWorkspace = true;
4+
# The PyO3 extension cdylib links cleanly only where undefined symbols are
5+
# allowed in a shared object (Linux), the same constraint that keeps ix's
6+
# native SDK wheels Linux-only. macOS needs `-undefined dynamic_lookup`, which
7+
# the shared cargo-unit graph does not thread through, so the wheel is built on
8+
# Linux (manylinux). Local macOS dev uses the editable build, not this package.
9+
flake.systems = [
10+
"x86_64-linux"
11+
"aarch64-linux"
12+
];
13+
packageSet.systems = [
14+
"x86_64-linux"
15+
"aarch64-linux"
16+
];
417
}

packages/tui-py/pyproject.toml

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
[build-system]
2-
requires = ["maturin>=1.7,<2"]
3-
build-backend = "maturin"
1+
# The wheel is built by Nix, not a PEP 517 backend: packages/tui-py/default.nix
2+
# compiles the PyO3 cdylib through the shared cargo-unit workspace graph and
3+
# packages it with wheel/mkwheel.py. This file stays as project metadata (the
4+
# version is read by the Nix build) plus the pyright config. There is no
5+
# `[build-system]`; `pip install .` is not a supported path. Build with
6+
# `nix build .#tui-py`.
47

58
[project]
69
name = "ix-tui"
@@ -19,12 +22,6 @@ classifiers = [
1922
]
2023
dependencies = ["numpy>=1.26"]
2124

22-
[tool.maturin]
23-
module-name = "tui._tui"
24-
python-source = "python"
25-
manifest-path = "Cargo.toml"
26-
features = ["pyo3/extension-module"]
27-
2825
[tool.pyright]
2926
include = ["python"]
3027
pythonVersion = "3.11"

packages/tui-py/wheel/mkwheel.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
"""Package a pre-built PyO3 cdylib plus the Python source into a PEP 427 wheel.
3+
4+
Nix builds the `tui-py` cdylib through cargo-unit and calls this to assemble the
5+
`ix-tui` wheel, so there is no maturin / PEP 517 backend in the loop. The
6+
extension is abi3 (`pyo3/abi3-py311`), hence the `cp311-abi3` tag: one wheel
7+
loads on CPython 3.11+.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import base64
14+
import hashlib
15+
import pathlib
16+
import zipfile
17+
18+
# Import package vs. PyPI distribution: `import tui`, but the wheel and
19+
# dist-info carry the distribution name `ix-tui` (normalized to `ix_tui`).
20+
PKG = "tui"
21+
DIST = "ix_tui"
22+
DIST_NAME = "ix-tui"
23+
SO_NAME = "_tui.abi3.so"
24+
# Files copied verbatim from the Python source tree into the wheel.
25+
SOURCE_FILES = ["__init__.py", "_tui.pyi", "py.typed"]
26+
27+
28+
def sha256_b64(data: bytes) -> str:
29+
digest = hashlib.sha256(data).digest()
30+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
31+
32+
33+
def record_line(name: str, data: bytes) -> str:
34+
return f"{name},sha256={sha256_b64(data)},{len(data)}"
35+
36+
37+
def build_wheel(
38+
*,
39+
cdylib: pathlib.Path,
40+
python_src: pathlib.Path,
41+
version: str,
42+
platform_tag: str,
43+
out: pathlib.Path,
44+
) -> pathlib.Path:
45+
tag = f"cp311-abi3-{platform_tag}"
46+
dist_info = f"{DIST}-{version}.dist-info"
47+
wheel_path = out / f"{DIST}-{version}-{tag}.whl"
48+
49+
files: dict[str, bytes] = {}
50+
for name in SOURCE_FILES:
51+
files[f"{PKG}/{name}"] = (python_src / PKG / name).read_bytes()
52+
files[f"{PKG}/{SO_NAME}"] = cdylib.read_bytes()
53+
54+
files[f"{dist_info}/METADATA"] = (
55+
"Metadata-Version: 2.4\n"
56+
f"Name: {DIST_NAME}\n"
57+
f"Version: {version}\n"
58+
"Summary: Python bindings for the tui PTY-backed terminal manager. "
59+
"Imported as `tui`.\n"
60+
"Author: indexable\n"
61+
"Requires-Python: >=3.11\n"
62+
"Requires-Dist: numpy>=1.26\n"
63+
).encode()
64+
files[f"{dist_info}/WHEEL"] = (
65+
"Wheel-Version: 1.0\n"
66+
"Generator: mkwheel\n"
67+
"Root-Is-Purelib: false\n"
68+
f"Tag: {tag}\n"
69+
).encode()
70+
71+
records = [record_line(name, data) for name, data in files.items()]
72+
records.append(f"{dist_info}/RECORD,,")
73+
files[f"{dist_info}/RECORD"] = "\n".join(records).encode() + b"\n"
74+
75+
out.mkdir(parents=True, exist_ok=True)
76+
# Deterministic order so the wheel hash is stable across builds.
77+
with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf:
78+
for name in sorted(files):
79+
zf.writestr(name, files[name])
80+
81+
return wheel_path
82+
83+
84+
def main() -> None:
85+
p = argparse.ArgumentParser()
86+
p.add_argument("--cdylib", type=pathlib.Path, required=True)
87+
p.add_argument("--python-src", type=pathlib.Path, required=True)
88+
p.add_argument("--version", default="0.1.0")
89+
p.add_argument("--platform-tag", default="manylinux_2_34_x86_64")
90+
p.add_argument("--out", type=pathlib.Path, required=True)
91+
args = p.parse_args()
92+
93+
print(
94+
build_wheel(
95+
cdylib=args.cdylib,
96+
python_src=args.python_src,
97+
version=args.version,
98+
platform_tag=args.platform_tag,
99+
out=args.out,
100+
)
101+
)
102+
103+
104+
if __name__ == "__main__":
105+
main()

0 commit comments

Comments
 (0)