Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/maple-agent/docs/python-code-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,11 @@ entitlements in `app/packaging/macos-python-entitlements.plist`: library
validation is off for it, so native extensions of wheels the tool installs can
load. `just debug-app` bundles it the same way with ad hoc signatures and runs
the worker smoke from inside the bundle.

Linux bundles stage it into `usr/share/maple-agent/python` when the AppDir is
built. The Nix package copies the locked nixpkgs `python314` (the same version
as the pin) into `share/maple-agent/python`.

Every runtime leaves out Tcl/Tk, tkinter, IDLE and turtledemo: the worker has no
use for Tk GUIs, and Python Build Standalone's Linux Tcl/Tk libraries carry an
absolute build-host library path.
36 changes: 36 additions & 0 deletions apps/maple-agent/flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
forPackageSystems = nixpkgs.lib.genAttrs packageSystems;
# The python tool's CPython for the Nix package. The store reference in
# runtime.json retains exactly this interpreter and its closure; the
# version must equal scripts/python-runtime.json's.
mkPythonRuntime =
pkgs:
let
python = pkgs.python314;
version = python.version;
manifest = {
protocol_version = 1;
implementation = "cpython";
inherit version;
distribution = "nix-${python.name}-${builtins.baseNameOf (toString python)}";
executable = "${python}/bin/python3.14";
worker = "worker.py";
};
in
assert version == (builtins.fromJSON (builtins.readFile ./scripts/python-runtime.json)).version;
pkgs.runCommand "maple-python-${version}" { nativeBuildInputs = [ python ]; } ''
runtime="$out/share/maple-agent/python"
mkdir -p "$runtime/licenses"
cp ${./crates/maple-code-mode/python/worker.py} "$runtime/worker.py"
cat > "$runtime/runtime.json" <<'JSON'
${builtins.toJSON manifest}
JSON
tar -xOf ${python.src} Python-${version}/LICENSE > "$runtime/licenses/Python-LICENSE.txt"
${python}/bin/python3.14 -I -B -c 'import sys, sysconfig, ssl, sqlite3, ctypes, zlib, bz2, lzma; assert sys.version_info[:3] == tuple(map(int, "${version}".split("."))); assert not sysconfig.get_config_var("Py_GIL_DISABLED")'
'';
in
{
packages = forPackageSystems (
Expand Down Expand Up @@ -68,8 +96,10 @@
fontconfig
freetype
] ++ linuxRuntimeInputs;
pythonRuntime = mkPythonRuntime pkgs;
in
{
python-runtime = pythonRuntime;
default = rustPlatform.buildRustPackage {
pname = "maple-agent";
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
Expand Down Expand Up @@ -134,6 +164,12 @@
# Keep the package derivation focused on producing the release binary.
doCheck = false;

# The python tool finds its CPython beside the binary's bin/.
postInstall = ''
mkdir -p "$out/share/maple-agent"
cp -R ${pythonRuntime}/share/maple-agent/python "$out/share/maple-agent/python"
'';

# GPUI loads the Wayland and Vulkan libraries at runtime, so they are
# not discovered by ELF dependency scanning. Prefer the NixOS GPU
# driver link and retain Mesa as a portable fallback elsewhere.
Expand Down
14 changes: 14 additions & 0 deletions apps/maple-agent/scripts/linux-release-appimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@


LOADER = "ld-linux-x86-64.so.2"
# The python tool's CPython (scripts/prepare-python.py), found beside the
# payload by the app's package resolver.
PYTHON_RUNTIME = "usr/share/maple-agent/python"
PYTHON_TARGET = "x86_64-unknown-linux-gnu"
# These ABI providers must come from the host, including when loading the
# host's graphics drivers. Never copy a build machine's libc into the bundle.
HOST_LIBRARIES = frozenset({
Expand Down Expand Up @@ -105,6 +109,15 @@ def needed(path):
return run("patchelf", "--print-needed", path).stdout.splitlines()


def stage_python(appdir, component):
"""Stage the python tool's CPython from its hash pin, copying hard links."""
with tempfile.TemporaryDirectory(prefix="maple-python-", dir=appdir.parent) as temporary:
runtime = Path(temporary) / "python"
subprocess.run([sys.executable, str(component / "scripts/prepare-python.py"), "--distribution", "pbs",
"--target", PYTHON_TARGET, "--destination", str(runtime)], check=True)
shutil.copytree(runtime, appdir / PYTHON_RUNTIME, symlinks=True)


def library_name(name):
if not re.fullmatch(r"lib[A-Za-z0-9_+.\-]+\.so(?:\.[A-Za-z0-9_.\-]+)*", name):
raise PackagingError(f"Invalid runtime library SONAME: {name}")
Expand Down Expand Up @@ -360,6 +373,7 @@ def main():
appdir.mkdir()
stage_payload(appdir, binary)
stage_metadata(appdir, component, metadata)
stage_python(appdir, component)
normalize_public_permissions(appdir)
for path in appdir.rglob("*"):
os.utime(path, (epoch, epoch), follow_symlinks=False)
Expand Down
23 changes: 22 additions & 1 deletion apps/maple-agent/scripts/prepare-python.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
PINS = Path(__file__).with_name("python-runtime.json")
RECEIPT = ".prepared.json"
LICENSES = Path(__file__).with_name("python-licenses")
# The worker has no use for Tk GUIs, and Python Build Standalone's Linux Tcl/Tk
# libraries carry an absolute build-host RPATH (/tools/deps/lib). Every bundle
# leaves Tcl/Tk, tkinter, IDLE and turtledemo out.
PRUNED = (
"bin/idle3*", "lib/libtcl9*", "lib/tcl9*", "lib/tk9*", "lib/itcl*", "lib/thread3*",
"lib/python3.*/tkinter", "lib/python3.*/idlelib", "lib/python3.*/turtledemo",
"lib/python3.*/lib-dynload/_tkinter.*",
"tcl", "Lib/tkinter", "Lib/idlelib", "Lib/turtledemo", "DLLs/_tkinter.pyd", "DLLs/tcl*.dll",
"DLLs/tk*.dll", "Scripts/idle*",
)


def digest(path):
Expand Down Expand Up @@ -122,6 +132,16 @@ def safe_extract(archive, destination):
return destination / "python"


def prune(runtime):
"""Remove PRUNED paths; symlinks are removed, never followed."""
for pattern in PRUNED:
for path in sorted(runtime.glob(pattern)):
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()


def inventory(root):
files = {}
for path in sorted(root.rglob("*")):
Expand Down Expand Up @@ -179,7 +199,7 @@ def prepare_pbs(target, destination, cache, worker, offline=False):
"executable": asset["executable"],
"worker": "worker.py",
}
identity = {"manifest": manifest, "archive_sha256": asset["sha256"], "worker_sha256": digest(worker), "licenses": inventory(LICENSES)}
identity = {"manifest": manifest, "archive_sha256": asset["sha256"], "worker_sha256": digest(worker), "licenses": inventory(LICENSES), "pruned": list(PRUNED)}
destination.parent.mkdir(parents=True, exist_ok=True)
with preparation_lock(destination.with_name(destination.name + ".lock")):
if reusable(destination, identity):
Expand All @@ -188,6 +208,7 @@ def prepare_pbs(target, destination, cache, worker, offline=False):
archive = download(asset, cache, offline)
with tempfile.TemporaryDirectory(prefix=".python-stage-", dir=destination.parent) as temporary:
runtime = safe_extract(archive, Path(temporary))
prune(runtime)
if not (runtime / asset["executable"]).is_file():
raise ValueError(f"Python archive lacks {asset['executable']}")
if not list(runtime.rglob("*LICENSE*")) and not list(runtime.rglob("*license*")):
Expand Down
37 changes: 37 additions & 0 deletions apps/maple-agent/scripts/test_prepare_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,43 @@ def test_verified_cache_and_stage_reused_corruption_repaired_offline(self):
prepare.prepare_pbs("test", destination, cache, worker, True)
self.assertEqual((destination / "worker.py").read_text(), "updated worker")

def test_tk_and_idle_are_left_out_of_every_runtime(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
archive = self.archive(root, [
("python/bin/python3.14", "binary", False),
("python/bin/idle3.14", "idle", False),
("python/bin/idle3", "idle3.14", True),
("python/LICENSE.txt", "license", False),
("python/lib/libtcl9.0.so", "tcl", False),
("python/lib/tcl9.0/init.tcl", "tcl", False),
("python/lib/tk9.0/tk.tcl", "tk", False),
("python/lib/python3.14/tkinter/__init__.py", "tkinter", False),
("python/lib/python3.14/idlelib/idle.py", "idle", False),
("python/lib/python3.14/lib-dynload/_tkinter.cpython-314-x86_64-linux-gnu.so", "ext", False),
("python/lib/python3.14/lib-dynload/_ssl.cpython-314-x86_64-linux-gnu.so", "ssl", False),
("python/lib/python3.14/json/__init__.py", "json", False),
])
archive_hash = hashlib.sha256(archive.read_bytes()).hexdigest()
cache = root / "cache"
cache.mkdir()
archive.rename(cache / f"{archive_hash}.tar.gz")
worker = root / "worker.py"
worker.write_text("worker")
pins = root / "pins.json"
pins.write_text(json.dumps({"implementation": "cpython", "version": "3.14.7", "release": "20260929", "targets": {"test": {"sha256": archive_hash, "executable": "bin/python3.14", "url": "unused"}}}))
destination = root / "runtime"
with patch.object(prepare, "PINS", pins), patch.object(prepare.urllib.request, "urlopen", side_effect=AssertionError("network forbidden")):
prepare.prepare_pbs("test", destination, cache, worker, True)
kept = sorted(path.relative_to(destination).as_posix() for path in destination.rglob("*")
if path.is_file() and not path.name.startswith("."))
self.assertIn("lib/python3.14/lib-dynload/_ssl.cpython-314-x86_64-linux-gnu.so", kept)
self.assertIn("lib/python3.14/json/__init__.py", kept)
# The pinned upstream notice set stays complete, including Tcl's.
for name in (name for name in kept if not name.startswith("licenses/")):
self.assertNotRegex(name, r"tcl|tk9|tkinter|idle")
self.assertFalse((destination / "bin/idle3").is_symlink())

def test_failed_publication_restores_previous_tree(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
Expand Down
Loading