makepad/tools/makepad_builder/windows/package.py
Admin d29ff812b7 tools, libs and the rest
git pack reads and cache bytes, image tiles for phone encoders, video
coverage tool, cargo launch helper, CI and builder updates; arch/ is
removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 07:24:22 +02:00

314 lines
16 KiB
Python

#!/usr/bin/env python3
"""The Windows Builder as source: the ZIP makepad.nl/builder/windows serves.
python3 tools/makepad_builder/windows/package.py [--rev REV] --out makepad-builder.zip
The ZIP holds makepad-builder.bat and, under builder/source/, the source of
the Builder (a console program: its TUI runs in the .bat's own window) with
the few small crates of this repository it depends on and nothing from
crates.io. makepad-builder.bat downloads Rust's official GNU toolchain,
compiles that source and runs it; no program of ours ships in binary form.
Everything the Builder writes later goes in builder/ too, so the folder
itself only ever holds the .bat, makepad-builder.exe and the apps.
Takes the files of REV (default HEAD) of every package in `cargo tree -p
makepad-loader --target all`: what compiles (the manifest without its
dev-dependencies, benches, examples and tests; src/, build.rs, the files
the code includes) and the licences, nothing else. Path dependencies cargo
only reads (another platform's, optional ones) go in as their manifest with
empty targets. builder/source/Cargo.toml is a generated workspace naming
only the Builder, with a lock, so the build needs no network. The same REV
gives the same bytes. Also writes FILE.sha256.
"""
import argparse
import hashlib
import io
import os
import re
import subprocess
import zipfile
from pathlib import Path
def git(repo, *args):
return subprocess.check_output(["git", "-C", str(repo), *args])
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--rev", default="HEAD")
parser.add_argument("--out", required=True, type=Path)
parser.add_argument("--package", default="makepad-loader", help="another package's closure (tests of the GNU chain)")
parser.add_argument("--worktree", action="store_true", help="file contents from the working tree, not REV (tests of uncommitted changes)")
args = parser.parse_args()
repo = Path(__file__).resolve().parents[3]
commit = git(repo, "rev-parse", args.rev).decode().strip()
# The packages, from the working tree's manifests (they must match REV).
tree = subprocess.check_output(
["cargo", "tree", "-p", args.package, "--target", "all", "-e", "normal,build",
"--prefix", "none", "--no-dedupe", "--offline"],
cwd=repo, text=True)
dirs = set()
member = None
for line in tree.splitlines():
match = re.search(r"\((/[^)]*)\)", line)
if not match:
# A crates.io package of another platform: resolved from
# Cargo.lock, never compiled here.
continue
dirs.add(os.path.relpath(match.group(1), repo))
if member is None:
member = os.path.relpath(match.group(1), repo)
manifests = [m for m in git(repo, "ls-tree", "-r", "--name-only", commit).decode().splitlines() if m.endswith("Cargo.toml")]
packages = {os.path.dirname(m) for m in manifests}
def owner(path):
d = os.path.dirname(path)
while d:
if d in packages:
return d
d = os.path.dirname(d)
return None
# Cargo reads the manifest of every path dependency, optional and other
# platforms' ones included, before it resolves features. Those it does
# not build go in as their manifest and empty target files.
import tomllib
def manifest(d):
if args.worktree:
return tomllib.loads((repo / d / "Cargo.toml").read_text())
return tomllib.loads(git(repo, "show", f"{commit}:{d}/Cargo.toml").decode())
# Dev-dependencies are dropped from the manifests below.
def path_deps(d, m):
out = []
tables = [m.get(k, {}) for k in ("dependencies", "build-dependencies")]
for spec in m.get("target", {}).values():
tables += [spec.get(k, {}) for k in ("dependencies", "build-dependencies")]
for table in tables:
for dep in table.values():
if isinstance(dep, dict) and "path" in dep:
out.append(os.path.normpath(os.path.join(d, dep["path"])))
return out
stubs = {}
todo = sorted(dirs)
seen = set()
while todo:
d = todo.pop()
if d in seen:
continue
seen.add(d)
m = manifest(d)
todo += [p for p in path_deps(d, m) if p in packages]
if d not in dirs:
targets = [m.get("lib", {}).get("path", "src/lib.rs")] if "lib" in m or not m.get("bin") else []
for kind in ("bin",):
targets += [t["path"] for t in m.get(kind, []) if "path" in t]
build = m.get("package", {}).get("build")
if build is None and subprocess.run(["git", "-C", str(repo), "cat-file", "-e", f"{commit}:{d}/build.rs"], stderr=subprocess.DEVNULL).returncode == 0:
build = "build.rs"
if isinstance(build, str):
targets.append(build)
stubs[d] = targets
def text_of(path, blob):
if args.worktree:
return (repo / path).read_text(errors="replace")
return git(repo, "cat-file", "blob", blob).decode(errors="replace")
# What compiles: src/ (less a test module only `cfg(test)` declares),
# build.rs, the manifest, and the files the code names (include_str!,
# include_bytes!, include!, a resources/ path); and the licences.
def wanted(path, package):
rel = os.path.relpath(path, package)
name = os.path.basename(rel)
if rel == "Cargo.toml" or rel == "build.rs" or rel.startswith("src/"):
return True
return "/" not in rel and re.match(r"(LICEN[CS]E|COPYING|NOTICE)", name, re.I) is not None
files = []
listing = git(repo, "ls-tree", "-r", "-z", "--full-tree", commit, "--", *sorted(dirs)).split(b"\0")
for entry in listing:
if not entry:
continue
meta, path = entry.decode().split("\t", 1)
mode, kind, blob = meta.split()
if kind != "blob" or owner(path) not in dirs:
continue
if not wanted(path, owner(path)):
continue
if "/tests/" in path or path.endswith(".wast"):
continue
if args.worktree and not (repo / path).is_file():
continue
files.append((path, blob, mode))
if args.worktree:
# New files not yet committed (a test of uncommitted work).
known = {path for path, _, _ in files}
extra = git(repo, "ls-files", "-z", "--others", "--exclude-standard", "--", *sorted(dirs)).decode().split("\0")
for path in extra:
if path and path not in known and owner(path) in dirs and "/tests/" not in path and wanted(path, owner(path)):
files.append((path, "worktree", "100644"))
# Files a build script names (its icon): dep-info does not list them.
build_resources = set()
# The files the code names, and no test module only tests compile.
everything = git(repo, "ls-tree", "-r", "-z", "--full-tree", "--name-only", commit, "--", *sorted(dirs)).decode().split("\0")
by_path = {path: (blob, mode) for path, blob, mode in files}
for path, blob, mode in list(files):
if not path.endswith(".rs"):
continue
text = text_of(path, blob)
package = owner(path)
for literal in re.findall(r'include(?:_str|_bytes)?!\(\s*"([^"]+)"', text) + re.findall(r'"((?:\.\./)*resources/[^"]+)"', text):
for base in (os.path.dirname(path), package):
named = os.path.normpath(os.path.join(base, literal))
if named in by_path or owner(named) != package:
continue
if named in everything or (args.worktree and (repo / named).is_file()):
blob_id = "worktree" if args.worktree else git(repo, "rev-parse", f"{commit}:{named}").decode().strip()
by_path[named] = (blob_id, "100644")
files.append((named, blob_id, "100644"))
if os.path.basename(path) == "build.rs" and named in by_path:
build_resources.add(named)
if re.search(r"#\[cfg\(test\)\]\s*mod tests;", text):
test = os.path.join(os.path.dirname(path), "tests.rs")
files = [f for f in files if f[0] != test]
for d, targets in stubs.items():
blob = git(repo, "rev-parse", f"{commit}:{d}/Cargo.toml").decode().strip()
files.append((f"{d}/Cargo.toml", blob, "100644"))
for target in targets:
files.append((os.path.normpath(os.path.join(d, target)), None, "100644")) # empty: never compiled
files.sort()
# Only the Builder is a member: a path dependency inside a workspace's
# directory would otherwise join it, and a member's every dependency,
# optional and dev ones included, must resolve.
others = sorted(d for d in set(dirs) | set(stubs) if d != member)
root = open(repo / "Cargo.toml").read()
def raw(path, blob):
if args.worktree and blob:
return (repo / path).read_bytes()
return git(repo, "cat-file", "blob", blob) if blob else b""
# Manifests of the packages that compile lose their dev-dependencies and
# bench, example and test targets (none of those files ship).
def content(path, blob):
data = raw(path, blob)
if os.path.basename(path) != "Cargo.toml" or os.path.dirname(path) not in dirs:
return data
out, skip = [], False
for line in data.decode().splitlines(keepends=True):
header = re.match(r"\s*\[\[?([^\]]+)\]", line)
if header:
table = header.group(1).strip()
skip = (table in ("bench", "example", "test")
or re.search(r"(^|\.)dev-dependencies(\.|$)", table) is not None)
if not skip:
out.append(line)
text = "".join(out)
# No auto-discovered targets from folders that are not there.
if "[package]" in text and "autobenches" not in text:
text = text.replace("[package]\n", "[package]\nautobenches = false\nautoexamples = false\nautotests = false\n", 1)
return text.encode()
# The first line names the exact source (makepad-builder.bat compiles
# again when it changes): the commit and a hash of every file's bytes.
def workspace_for(files):
tree = hashlib.sha256()
for path, blob, _ in files:
tree.update(path.encode() + b"\0" + content(path, blob) + b"\0")
return (
"# Generated by tools/makepad_builder/windows/package.py from makepad "
+ commit + " (source " + tree.hexdigest()[:16] + ").\n[workspace]\nmembers = [\"" + member + "\"]\nresolver = \"2\"\nexclude = [\n"
+ "".join(f" \"{d}\",\n" for d in others) + "]\n\n"
# The root's crates-io patches whose package is in this graph (a
# crate that names windows-link by version still gets the one here).
+ "".join(
("[patch.crates-io]\n" if i == 0 else "") + line + "\n"
for i, line in enumerate(
line for line in re.findall(r'^(\S+ = \{ path = "([^"]+)" \})$', root, re.M) and [
m.group(0) for m in re.finditer(r'^\S+ = \{ path = "([^"]+)" \}$', root.split("[patch.crates-io]", 1)[-1].split("\n[", 1)[0], re.M)
if os.path.normpath(m.group(1)) in dirs
] or []
)
)
+ "\n[profile.release]\nincremental = false\n"
)
workspace = workspace_for(files)
# The lock, from the generated workspace, so the build resolves nothing.
import tempfile
with tempfile.TemporaryDirectory() as scratch:
for d in sorted(dirs):
Path(scratch, d).mkdir(parents=True, exist_ok=True)
for path, blob, mode in files:
target = Path(scratch, path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content(path, blob))
Path(scratch, "Cargo.toml").write_text(workspace)
# Start from the repository's lock (its versions are the ones this
# tree builds with); cargo trims it to this workspace. An optional
# dependency nothing enables and nothing has locked is then never
# looked for (unicode-bidi names a ../smallvec that does not exist).
if (repo / "Cargo.lock").is_file():
Path(scratch, "Cargo.lock").write_bytes((repo / "Cargo.lock").read_bytes())
subprocess.check_call(["cargo", "metadata", "--offline", "--format-version", "1"], cwd=scratch, stdout=subprocess.DEVNULL)
lock = Path(scratch, "Cargo.lock").read_bytes()
# Exactly the files a Windows build reads: compile it for Windows
# here (a check, with the build scripts run) and take every source
# file the compiler's dep-info names. Another platform's modules and
# the files only they include stay out. Needs the
# x86_64-pc-windows-gnu standard library (rustup target add).
subprocess.check_call(
["cargo", "check", "--offline", "--locked", "--quiet", "--release", "--target", "x86_64-pc-windows-gnu",
"-p", "makepad-loader", "--bin", "makepad-builder"],
cwd=scratch, env={**os.environ, "CARGO_TARGET_DIR": str(Path(scratch, ".target"))})
used = set()
real = os.path.realpath(scratch)
for dep_file in Path(scratch, ".target").rglob("*.d"):
for word in re.split(r"(?<!\\)\s+", dep_file.read_text()):
# Paths inside the workspace are relative to it.
word = word.rstrip(":").replace("\\ ", " ")
for prefix in (real + "/", scratch + "/"):
if word.startswith(prefix):
word = word[len(prefix):]
if word and not os.path.isabs(word):
used.add(os.path.normpath(word))
def ships(path):
name = os.path.basename(path)
return (path in used or name == "Cargo.toml" or name == "build.rs" or path in build_resources
or re.match(r"(LICEN[CS]E|COPYING|NOTICE)", name, re.I) is not None)
files = [f for f in files if ships(f[0])]
workspace = workspace_for(files)
bat = (Path(__file__).parent / "makepad-builder.bat").read_bytes()
if not args.worktree:
bat = git(repo, "show", f"{commit}:tools/makepad_builder/windows/makepad-builder.bat")
# cmd.exe needs CRLF: with bare LF its labels and goto misparse and the
# window closes at once. Git keeps the file with LF (eol=crlf applies to
# checkouts only), so the ZIP always gets CRLF, whatever the source had.
bat = bat.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
assert b"\r\r" not in bat
data = io.BytesIO()
with zipfile.ZipFile(data, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
def add(name, content):
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
z.writestr(info, content)
add("makepad-builder.bat", bat)
add("builder/source/Cargo.toml", workspace.encode())
add("builder/source/Cargo.lock", lock)
for path, blob, mode in files:
add("builder/source/" + path, content(path, blob))
args.out.write_bytes(data.getvalue())
digest = hashlib.sha256(data.getvalue()).hexdigest()
Path(str(args.out) + ".sha256").write_text(f"{digest} {args.out.name}\n")
print(f"{args.out}: {len(files)} files, {len(dirs)} packages built, {len(stubs)} manifests only, {len(data.getvalue()) // 1024} KB, sha256 {digest}, makepad {commit[:9]}")
if __name__ == "__main__":
main()