_ _
| |_ ___| |_ _
| | . | | | |
|_|_|___|_|_ |
|___|
git mirror - github.com/owenewans/holy - branch master
file tests/brew.py
#!/usr/bin/env python3
"""converts Homebrew formulas into holy recipes, builds the mechanical one and
refuses the formulas that name no source."""
import hashlib
import io
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
binary = str(Path(sys.argv[1]).resolve())
def call(*args, status=0):
result = subprocess.run([binary, *map(str, args)], capture_output=True, text=True,
errors="replace")
assert result.returncode == status, (args, result.returncode, result.stdout, result.stderr)
return result.stdout
def convert(formula, out, status=0):
return call("convert", formula, "--source", "brew", "--output", out, status=status)
def source_tree(root, name, version):
directory = root / f"{name}-{version}"
directory.mkdir(parents=True)
(directory / "Makefile").write_text(
"CFLAGS ?= -O2 -pipe -static\n"
"all: bin/%(name)s\n"
"bin/%(name)s: main.c\n"
"\tmkdir -p bin\n"
"\t$(CC) $(CFLAGS) -o $@ main.c\n"
"install: bin/%(name)s\n"
"\tmkdir -p $(DESTDIR)/usr/bin\n"
"\tcp bin/%(name)s $(DESTDIR)/usr/bin/\n" % {"name": name})
(directory / "main.c").write_text(
'#include <stdio.h>\nint main(void){printf("%s\\n");return 0;}\n' % name)
return directory
def main():
with tempfile.TemporaryDirectory() as scratch:
root = Path(scratch)
tree = source_tree(root, "brewfruit", "3.1")
archive = root / "brewfruit-3.1.tar.gz"
subprocess.run(["tar", "czf", str(archive), "-C", str(root), tree.name], check=True)
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
# a formula that is only a source url and a build command converts natively,
# and the recipe it produced builds a real package
simple = root / "Brewfruit.rb"
simple.write_text(
"class Brewfruit < Formula\n"
' desc "a fixture program for the Homebrew converter"\n'
' homepage "https://example.org/brewfruit"\n'
' url "%s"\n'
' sha256 "%s"\n'
' license "MIT"\n'
' revision 4\n'
' depends_on "pkg-config"\n'
' depends_on "zlib" => :build\n'
" def install\n"
' system "make", "DESTDIR=$HOLY_DEST", "install"\n'
" end\n"
"end\n" % (archive, digest))
out = root / "simple"
report = convert(simple, out)
assert "converted brewfruit status native" in report, report
recipe = (out / "brewfruit.recipe").read_text()
assert 'name "brewfruit"\n' in recipe, recipe
# a formula records no version, so the source url states it
assert 'version "3.1"\n' in recipe, recipe
# the revision is the release a package record holds
assert 'release "4"\n' in recipe, recipe
assert 'source source "%s"\n' % archive in recipe, recipe
assert 'source-sha256 source "%s"\n' % digest in recipe, recipe
assert 'build-depend "zlib" "any" "-"\n' in recipe, recipe
assert 'depend "pkg-config" "any" "-"\n' in recipe, recipe
assert 'output "brewfruit" runtime\n' in recipe, recipe
assert "step build /bin/sh <<BUILD\ncd \"$HOLY_SRC\" || exit 1\n"
assert "'make' \"DESTDIR=$HOLY_DEST\" 'install'\nBUILD\n" in recipe, recipe
summary = (out / "conversion").read_text()
assert "status native\n" in summary, summary
assert "license MIT" in summary, summary
assert "the revision 4 becomes the release" in summary, summary
assert "the version 3.1 comes from the source url" in summary, summary
assert "dependency runtime 1 build 1 optional 0 sources 1" in summary, summary
built = root / "built"
call("build", out / "brewfruit.recipe", "--output", built, "--yes")
artifacts = sorted(p.name for p in built.glob("*.holy"))
# a static payload carries no runtime, so the group is the one the recipe named
assert artifacts == ["brewfruit--x86_64--nolibc.holy"], artifacts
artifact = built / artifacts[0]
call("verify", "local:" + str(artifact))
identity = call("info", "local:" + str(artifact))
assert "name brewfruit\n" in identity, identity
assert "version 3.1\n" in identity, identity
assert "release 4\n" in identity, identity
assert "arch x86_64\n" in identity, identity
assert "libc nolibc\n" in identity, identity
listing = call("manifest", "local:" + str(artifact))
assert "usr/bin/brewfruit" in listing, listing
target = root / "root"
target.mkdir()
call("db", "init", "--root", target)
call("cache", "stage", "local:" + str(artifact), "--root", target)
artifact_digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
# the depends_on the formula stated is a package requirement, so the set is
# unresolved until a source provides it
requirements = call("requirements", "local:" + str(artifact))
assert 'require "brewfruit-req-0" "brewfruit" "package" "pkg-config" "any" "any" ' \
'"any" "-" "recipe-depend" "holy-recipe"' in requirements, requirements
result = subprocess.run([binary, "db", "plan-set", artifact_digest, "--root", target],
capture_output=True, text=True)
assert result.returncode == 4, result
assert "unresolved requirement brewfruit-req-0" in result.stderr, result.stderr
provider_tree = root / "provider"
(provider_tree / "HOLY").mkdir(parents=True)
(provider_tree / "DATA/usr/bin").mkdir(parents=True)
# a script needs an interpreter, so the provider carries a static shell
(provider_tree / "DATA/bin").mkdir(parents=True)
shell_source = root / "sh.c"
shell_source.write_text("int main(void) { return 0; }\n")
shell = False
for compiler in ("cc", "gcc", "clang"):
built_shell = subprocess.run([compiler, "-static", str(shell_source), "-o", str(
provider_tree / "DATA/bin/sh")], capture_output=True)
if built_shell.returncode == 0:
shell = True
break
assert shell, "a static shell is required for the script provider fixture"
(provider_tree / "DATA/usr/bin/pkg-config").write_text("#!/bin/sh\nexit 0\n")
(provider_tree / "DATA/usr/bin/pkg-config").chmod(0o755)
(provider_tree / "HOLY/meta").write_text(
"format holy-package-1\nname pkg-config\nversion 1\nrelease 1\nos linux\n"
"arch x86_64\nlibc nolibc\n")
for field, body in (("deps", ""), ("hooks", ""), ("origin", ""), ("transform", ""),
("provides", "provide package pkg-config x86_64 nolibc - metadata\n")):
(provider_tree / "HOLY" / field).write_text(body)
call("manifest", "generate", provider_tree, "--output", root / "provider-files")
(provider_tree / "HOLY/files").write_text((root / "provider-files").read_text())
call("pack", provider_tree, "--output", root / "provider.holy")
provider_digest = hashlib.sha256((root / "provider.holy").read_bytes()).hexdigest()
call("cache", "stage", "local:" + str(root / "provider.holy"), "--root", target)
plan = call("db", "plan-set", artifact_digest, provider_digest, "--root", target)
assert "requirement brewfruit-req-0 consumer %s provider %s package pkg-config" % (
artifact_digest, provider_digest) in plan, plan
plan_hash = [line.split()[10] for line in plan.splitlines()
if line.startswith("plan-set ")][0]
applied = call("db", "apply-set", plan_hash, artifact_digest, provider_digest,
"--root", target)
assert "artifacts 2" in applied, applied
assert (target / "usr/bin/brewfruit").is_file(), sorted(
str(p.relative_to(target)) for p in target.rglob("*") if p.is_file())
call("db", "check", "--all", "--root", target)
# a formula with Ruby this converter does not translate keeps the formula
# beside the recipe and names every line it could not carry
rich = root / "Rich.rb"
rich.write_text('''class Rich < Formula
desc "a formula that mixes shell, Ruby and platform blocks"
url "https://example.org/rich-2.0.tar.gz"
sha256 "%s"
head "https://github.com/example/rich.git", branch: "main"
mirror "https://mirror.example.org/rich-2.0.tar.gz"
depends_on "readline" => :recommended
uses_from_macos "CoreFoundation"
on_macos do
system "clang", "-arch", "-64", "install"
end
on_linux do
system "meson", "setup", "build", "--prefix=#{prefix}"
end
resource "rich-data" do
url "https://example.org/rich-data-2.0.tar.gz"
sha256 "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
end
bottle do
sha256 cellar: :any, sonoma: "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
end
def install
system "./configure", "--prefix=#{prefix}", "--libdir=#{libexec}"
system "make", "install"
system "make", "install", "PREFIX=#{weird_value}"
inreplace "Makefile", "PREFIX=/usr", "PREFIX=#{prefix}"
end
test do
assert_equal "2.0", shell_output("#{bin}/rich --version")
end
end
''' % digest)
rich_out = root / "rich"
report = convert(rich, rich_out, status=3)
assert "converted rich status review-required" in report, report
recipe = (rich_out / "rich.recipe").read_text()
# the Homebrew prefix is the build root, so the interpolation becomes a
# build path rather than a cellar
assert '"--prefix=$HOLY_DEST"' in recipe, recipe
assert '"--libdir=$HOLY_DEST/libexec"' in recipe, recipe
assert "#{" not in recipe.split("source-sha256")[0], recipe
# an interpolation this reader does not model is kept and named
assert "'PREFIX=#{weird_value}'" in recipe, recipe
# a resource with a pinned digest is a fetched source
assert 'source "resource-rich-data" "https://example.org/rich-data-2.0.tar.gz"\n' \
in recipe, recipe
summary = (rich_out / "conversion").read_text()
assert "status review-required\n" in summary, summary
assert "the head at line 5 is a development checkout" in summary, summary
assert "the directive mirror at line 6 is not one this converter reads" in summary, summary
assert "the dependency readline at line 7 is recommended" in summary, summary
assert "the macOS framework CoreFoundation at line 8" in summary, summary
assert "the on_macos block at line 9" in summary, summary
assert "the on_linux block at line 12 is carried" in summary, summary
assert "the resource rich-data at line 15 becomes a fetched source" in summary, summary
assert "the bottle block at line 19 is a prebuilt foreign binary" in summary, summary
assert "the install statement at line 26 is Ruby" in summary, summary
assert "the test block at line 28 is Ruby" in summary, summary
assert "the interpolation at line 25 names a Homebrew value" in summary, summary
# the Ruby that did not translate travels beside the recipe with its digest
assert "preserved homebrew-install.rb " in summary, summary
assert (rich_out / "homebrew-install.rb").read_text() == rich.read_text()
# the build needs an interpreter for it
assert 'build-depend "ruby" "any" "-"\n' in recipe, recipe
assert "the install body keeps 1 Ruby statements" in summary, summary
# a formula that states no source names no version and no build
for body, wanted in (
('class NoSource < Formula\n desc "nothing to fetch"\nend\n',
"states no url"),
('class NoDigest < Formula\n url "https://example.org/x-1.0.tar.gz"\nend\n',
"states no sha256"),
('class Casky < Cask\n url "https://example.org/app.zip"\n sha256 "aa"\nend\n',
"inherits from a cask"),
('class Broken < Formula\n url "https://example.org/b-1.0.tar.gz"\n'
' sha256 "aa"\n def install\n system "make"\n',
"block that never ends")):
case = root / (wanted.split()[0] + str(len(body)) + ".rb")
case.write_text(body)
result = subprocess.run([binary, "convert", case, "--source", "brew", "--output",
root / (case.stem + "-out")], capture_output=True, text=True)
if wanted == "inherits from a cask":
# a cask still names its source, so the report carries the refusal
assert result.returncode == 3, (wanted, result)
assert "inherits from a cask" in (root / (case.stem + "-out") / "conversion").read_text()
continue
# a formula with no source, no digest or an unterminated block is refused
# with the reason, and a cask is a report instead, since it names a source
assert result.returncode in (1, 2, 3), (wanted, result)
if result.returncode == 3:
report = (root / (case.stem + "-out") / "conversion").read_text()
assert wanted.split()[0] in report or wanted in report, (wanted, report)
else:
assert wanted in result.stderr, (wanted, result.stderr)
# a file that is not there, and one that is not text
result = subprocess.run([binary, "convert", root / "absent.rb", "--source", "brew",
"--output", root / "absent-out"], capture_output=True, text=True)
assert result.returncode == 6, result
assert "formula unavailable" in result.stderr, result.stderr
binary_formula = root / "Binary.rb"
binary_formula.write_bytes(b"\x00\x01\x02class Nope < Formula\n")
result = subprocess.run([binary, "convert", binary_formula, "--source", "brew",
"--output", root / "binary-out"], capture_output=True, text=True)
assert result.returncode == 2, result
assert "Ruby text" in result.stderr, result.stderr
# a conversion without a source name is refused before the file is read
result = subprocess.run([binary, "convert", simple, "--source", "local", "--output",
root / "local-out"], capture_output=True, text=True)
assert result.returncode == 2, result
assert "needs the source name" in result.stderr, result.stderr
result = subprocess.run([binary, "convert", simple, "--source", "a/b", "--output",
root / "slash-out"], capture_output=True, text=True)
assert result.returncode == 2, result
assert "invalid source name" in result.stderr, result.stderr
assert not (root / "slash-out").exists()
print("homebrew fixtures passed")
return 0
if __name__ == "__main__":
sys.exit(main())