SPS · log · files · refs · tags · releases · people · languages · GitHub · file
tools/new-recipe
27320 bytes at 2909919 · GitHub
#!/usr/bin/env python3
"""Draft SPS recipes for upstream projects.
Writing a recipe by hand means finding the newest release, downloading it,
computing a SHA-256, working out the build system, and translating upstream's
dependency names into SPS package names. This tool does that from upstream
metadata so a maintainer reviews a draft instead of typing one.
Every recipe it writes carries a real digest. Nothing is guessed: a source
that cannot be resolved and hashed is reported and skipped.
tools/new-recipe --tree /usr/src/sps/extra pypi:requests
tools/new-recipe --tree /usr/src/sps/extra --category devel pypi:pytest
tools/new-recipe --tree /usr/src/sps/extra --from-file wanted.txt
tools/new-recipe --tree /usr/src/sps/extra url:https://x.org/foo-1.2.tar.xz
A drafted recipe is a starting point, not a finished package. Build it before
you commit it, and run tools/audit-recipes over the tree afterwards.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import hashlib
import io
import json
import os
import re
import ssl
import sys
import tarfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
# See tools/audit-recipes: a browser User-Agent makes SourceForge and friends
# answer with an HTML interstitial instead of the archive.
UA = "curl/8.5.0"
PYPI = "https://pypi.org/pypi/{}/json"
CRATES = "https://crates.io/api/v1/crates/{}"
METACPAN = "https://fastapi.metacpan.org/v1/release/{}"
GITHUB_API = "https://api.github.com"
# PyPI build backends that SPS packages, so --no-build-isolation works.
BACKEND_PACKAGES = {
"setuptools.build_meta": "python-setuptools",
"setuptools.build_meta:__legacy__": "python-setuptools",
"flit_core.buildapi": "python-flit-core",
"flit.buildapi": "python-flit-core",
"hatchling.build": "python-hatchling",
"hatchling.ouroboros": "python-hatchling",
"poetry.core.masonry.api": "python-poetry-core",
"poetry.masonry.api": "python-poetry-core",
"pdm.backend": "python-pdm-backend",
"maturin": "python-maturin",
"scikit_build_core.build": "python-scikit-build-core",
"mesonpy": "python-meson-python",
}
# Upstream pkg-config / distribution names that do not match the SPS name.
NAME_OVERRIDES = {
"pillow": "python-pillow",
"pyyaml": "python-yaml",
"beautifulsoup4": "python-beautifulsoup4",
}
# Staged Python modules must end up under /usr/lib64, which is where a CPython
# configured with --with-platlibdir=lib64 looks. Written as one logical recipe
# line with continuations so it stays a single `install` record.
PY_LIB64_FIXUP = (
'pyver=$(python3 -c \'import sys; print("%d.%d" % sys.version_info[:2])\'); \\\n'
' if [ -d "$PKG/usr/lib/python$pyver" ]; then \\\n'
' mkdir -p "$PKG/usr/lib64"; \\\n'
' cp -a "$PKG/usr/lib/python$pyver/." "$PKG/usr/lib64/python$pyver/"; \\\n'
' rm -rf "$PKG/usr/lib/python$pyver"; \\\n'
' rmdir "$PKG/usr/lib" 2>/dev/null || :; fi'
)
class ResolveError(RuntimeError):
pass
def http_bytes(url: str, timeout: int = 60, accept: str = "") -> bytes:
headers = {"User-Agent": UA}
if accept:
headers["Accept"] = accept
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token and "api.github.com" in url:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
ctx = ssl.create_default_context()
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return resp.read()
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
raise ResolveError(f"{url}: {exc}") from exc
def http_json(url: str, timeout: int = 60) -> dict:
try:
return json.loads(http_bytes(url, timeout, "application/json"))
except json.JSONDecodeError as exc:
raise ResolveError(f"{url}: invalid JSON: {exc}") from exc
def fetch_and_hash(url: str, timeout: int = 900) -> tuple[bytes, str]:
data = http_bytes(url, timeout)
return data, hashlib.sha256(data).hexdigest()
def sps_python_name(dist: str) -> str:
"""Translate a PyPI distribution name into an SPS package name."""
slug = re.sub(r"[_.]+", "-", dist.strip().lower())
if slug in NAME_OVERRIDES:
return NAME_OVERRIDES[slug]
if slug.startswith("python-"):
return slug
return f"python-{slug}"
def parse_requires_dist(entries: list[str] | None) -> list[str]:
"""Runtime dependencies only: drop extras and non-matching markers.
A requirement guarded by `extra == "..."` is optional by definition, and
one guarded by a python_version or sys_platform marker that cannot apply
here would install a package nothing imports.
"""
out: list[str] = []
for entry in entries or []:
spec = entry.split(";", 1)
name = re.split(r"[\s(\[<>=!~]", spec[0].strip(), maxsplit=1)[0].strip()
if not name:
continue
if len(spec) > 1:
marker = spec[1]
if "extra" in marker:
continue
if "sys_platform" in marker and "linux" not in marker:
continue
if re.search(r"platform_system\s*==\s*[\"'](Windows|Darwin)", marker):
continue
mapped = sps_python_name(name)
if mapped not in out:
out.append(mapped)
return out
def read_member(data: bytes, filename: str, wanted: str) -> str | None:
"""Read one file out of an sdist without unpacking it to disk."""
try:
if filename.endswith(".zip"):
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for name in zf.namelist():
if name.split("/", 1)[-1] == wanted:
return zf.read(name).decode("utf-8", "replace")
return None
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf:
for member in tf.getmembers():
if not member.isfile():
continue
parts = member.name.split("/", 1)
if len(parts) == 2 and parts[1] == wanted:
handle = tf.extractfile(member)
if handle is None:
return None
return handle.read().decode("utf-8", "replace")
except (tarfile.TarError, zipfile.BadZipFile, OSError, EOFError):
return None
return None
def detect_pypi_backend(data: bytes, filename: str) -> str | None:
"""Return the SPS package providing this sdist's build backend.
None means no build dependency is needed. That is the case for a
self-hosting backend: setuptools and flit_core declare `backend-path`, so
pip imports the backend out of the unpacked sdist itself and the package
can be built before any backend is installed. Without that, the bootstrap
would be circular.
"""
pyproject = read_member(data, filename, "pyproject.toml")
if pyproject:
if re.search(r"^\s*backend-path\s*=", pyproject, re.M):
return None
match = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', pyproject)
if match:
backend = match.group(1).strip()
if backend in BACKEND_PACKAGES:
return BACKEND_PACKAGES[backend]
root = backend.split(".", 1)[0].split(":", 1)[0]
return f"python-{root.replace('_', '-')}"
# A pyproject.toml with no build-backend defaults to setuptools.
return "python-setuptools"
return "python-setuptools"
def resolve_pypi(spec: str) -> dict:
dist = spec
doc = http_json(PYPI.format(urllib.parse.quote(dist)))
info = doc["info"]
version = info["version"]
sdist = None
for entry in doc.get("urls", []):
if entry.get("packagetype") == "sdist":
sdist = entry
break
if sdist is None:
raise ResolveError(f"pypi:{dist} {version} publishes no source distribution")
url = sdist["url"]
digest = sdist["digests"]["sha256"]
filename = sdist["filename"]
# The sdist is needed only to read pyproject.toml. Verify it against the
# digest PyPI published rather than trusting the transfer.
backend: str | None = "python-setuptools"
try:
data = http_bytes(url, 300)
if hashlib.sha256(data).hexdigest() != digest:
raise ResolveError(f"pypi:{dist}: sdist digest does not match PyPI metadata")
backend = detect_pypi_backend(data, filename)
except ResolveError:
raise
except Exception: # noqa: BLE001 - backend detection is best effort
pass
summary = (info.get("summary") or f"{dist} Python module").strip()
summary = summary.rstrip(".")
name = sps_python_name(dist)
depends = ["filesystem", "python"] + parse_requires_dist(info.get("requires_dist"))
# python is built --with-ensurepip=install, so pip needs no package.
builddeps = [backend] if backend else []
# Rebuild the URL from ${version} where the filename embeds it, so a later
# version bump only has to change one line.
source = url
if f"-{version}." in url:
source = url.replace(f"-{version}.", "-${version}.")
return {
"name": name,
"version": version,
"description": f"{name}: {summary}",
"arch": "any",
"source": source,
"hash": digest,
"depend": depends,
"builddep": [d for d in builddeps if d != name],
"phases": [
("install", 'python3 -m pip install --no-deps --no-build-isolation '
'--no-compile --root="$PKG" --prefix=/usr .'),
# pip follows the running interpreter's install scheme. SPS builds
# CPython with --with-platlibdir=lib64, but a build driven by a
# host interpreter that uses plain "lib" would stage the module
# where the target python never looks. Normalize it either way.
("install", PY_LIB64_FIXUP),
],
}
def resolve_crate(spec: str) -> dict:
doc = http_json(CRATES.format(urllib.parse.quote(spec)))
crate = doc["crate"]
version = crate["max_stable_version"]
checksum = None
for entry in doc.get("versions", []):
if entry.get("num") == version:
checksum = entry.get("checksum")
break
if not checksum:
raise ResolveError(f"crate:{spec}: no checksum for {version}")
summary = (crate.get("description") or f"{spec} Rust crate").strip().rstrip(".")
return {
"name": spec,
"version": version,
"description": f"{spec}: {summary}",
"arch": "",
"source": f"https://static.crates.io/crates/{spec}/{spec}-${{version}}.crate",
"hash": checksum,
"depend": ["filesystem"],
"builddep": ["rust"],
"phases": [
("build", 'cargo build --release --locked'),
("install", 'install -Dm755 "target/release/%s" "$PKG/usr/bin/%s"' % (spec, spec)),
],
}
# Dual-life distributions that ship inside perl itself. They exist on CPAN, so
# nothing stops a resolver from drafting them, but packaging them separately
# would shadow files the perl package already owns. Depending on them is also
# pointless: installing perl already satisfies the requirement.
CORE_PERL_DISTS = {
"archive-tar", "attribute-handlers", "autodie", "autouse", "base",
"bignum", "carp", "compress-raw-bzip2", "compress-raw-lzma",
"compress-raw-zlib", "config-perl-v", "constant", "cpan", "data-dumper",
"db_file", "devel-ppport", "digest", "digest-md5", "digest-sha",
"dumpvalue", "encode", "encoding-warnings", "env", "errno",
"experimental", "exporter", "extutils-cbuilder", "extutils-constant",
"extutils-install", "extutils-makemaker", "extutils-manifest",
"extutils-parsexs", "file-fetch", "file-path", "file-temp",
"filter-simple", "filter-util-call", "getopt-long", "i18n-langtags",
"if", "io", "io-compress", "io-socket-ip", "io-zlib", "ipc-cmd",
"ipc-sysv", "json-pp", "libnet", "locale-maketext",
"locale-maketext-simple", "math-bigint", "math-bigint-fastcalc",
"math-bigrat", "math-complex", "memoize", "mime-base64",
"module-corelist", "module-load", "module-load-conditional",
"module-loaded", "module-metadata", "net-ping", "params-check",
"parent", "pathtools", "perl-ostype", "perlfaq", "perlio-via-quotedprint",
"pod-checker", "pod-escapes", "pod-perldoc", "pod-simple", "pod-usage",
"podlators", "safe", "scalar-list-utils", "search-dict",
"selfloader", "socket", "storable", "sys-syslog", "term-ansicolor",
"term-cap", "term-complete", "term-readline", "test", "test-harness",
"test-simple", "text-abbrev", "text-balanced", "text-parsewords",
"text-tabs+wrap", "thread-queue", "thread-semaphore", "tie-file",
"tie-refhash", "time-hires", "time-local", "time-piece",
"unicode-collate", "unicode-normalize", "version", "xsloader",
}
_CPAN_DIST_CACHE: dict[str, str | None] = {}
def cpan_distribution_of(module: str) -> str | None:
"""Map a Perl module name to its CPAN distribution.
Returns None for modules that ship with perl itself, which must not become
packages: there is no separate release to build, and depending on one
would be an unsatisfiable dependency.
"""
if module in _CPAN_DIST_CACHE:
return _CPAN_DIST_CACHE[module]
result: str | None = None
try:
doc = http_json(
"https://fastapi.metacpan.org/v1/module/"
f"{urllib.parse.quote(module)}?fields=distribution", 30)
dist = doc.get("distribution")
if dist and dist != "perl" and dist.lower() not in CORE_PERL_DISTS:
result = dist
except ResolveError:
result = None
_CPAN_DIST_CACHE[module] = result
return result
def cpan_release(module: str) -> dict:
"""Fetch a release document, tolerating the wrong case.
The /release/NAME endpoint matches the distribution name exactly, so
"html-treebuilder" 404s where "HTML-Tree" succeeds. Fall back to a search
that is case-insensitive and also resolves a module name to its release.
"""
try:
return http_json(METACPAN.format(urllib.parse.quote(module)))
except ResolveError:
pass
query = urllib.parse.quote(module.replace("-", " "))
url = ("https://fastapi.metacpan.org/v1/release/_search"
f"?q=distribution:{query}%20AND%20status:latest&size=5")
doc = http_json(url)
hits = doc.get("hits", {}).get("hits", [])
for hit in hits:
source = hit.get("_source", {})
if (source.get("distribution", "")).lower() == module.lower():
return source
# A module name rather than a distribution name: ask which release owns it.
owner = http_json(
"https://fastapi.metacpan.org/v1/module/"
f"{urllib.parse.quote(module.replace('-', '::'))}?fields=distribution", 30)
dist = owner.get("distribution")
if not dist:
raise ResolveError(f"cpan:{module}: no release found")
return http_json(METACPAN.format(urllib.parse.quote(dist)))
def cpan_runtime_depends(doc: dict) -> list[str]:
modules: list[str] = []
for entry in doc.get("dependency") or []:
if entry.get("phase") != "runtime":
continue
if entry.get("relationship") != "requires":
continue
module = entry.get("module", "")
if not module or module == "perl":
continue
modules.append(module)
out: list[str] = []
for module in dict.fromkeys(modules):
dist = cpan_distribution_of(module)
if not dist:
continue
name = "perl-" + dist.lower()
if name not in out:
out.append(name)
return out
def resolve_cpan(spec: str) -> dict:
module = spec.replace("::", "-")
doc = cpan_release(module)
version = str(doc["version"])
url = doc["download_url"]
_data, digest = fetch_and_hash(url)
summary = (doc.get("abstract") or f"{spec} Perl module").strip().rstrip(".")
dist = doc.get("distribution") or module
name = "perl-" + dist.lower()
source = url.replace(f"-{version}.", "-${version}.") \
if f"-{version}." in url else url
uses_build_pl = doc.get("metadata", {}).get("generated_by", "")
phases = [
("configure", 'perl Makefile.PL INSTALLDIRS=vendor'),
("build", 'make -j"$MAKEJOBS"'),
("install", 'make install DESTDIR="$PKG"'),
]
if "Module::Build" in uses_build_pl:
phases = [
("configure", 'perl Build.PL installdirs=vendor'),
("build", './Build'),
("install", './Build install destdir="$PKG"'),
]
depends = ["filesystem", "perl"]
for dep in cpan_runtime_depends(doc):
if dep != name and dep not in depends:
depends.append(dep)
return {
"name": name,
"version": version,
"description": f"{name}: {summary}",
"arch": "",
"source": source,
"hash": digest,
"depend": depends,
"builddep": [],
"phases": phases,
}
def resolve_github(spec: str) -> dict:
if "/" not in spec:
raise ResolveError(f"github:{spec} must be owner/repo")
owner, repo = spec.split("/", 1)
tags = http_json(f"{GITHUB_API}/repos/{owner}/{repo}/tags?per_page=50")
chosen = None
for tag in tags:
name = tag.get("name", "")
if re.search(r"(alpha|beta|rc|pre|dev|snapshot)", name, re.I):
continue
chosen = name
break
if not chosen:
raise ResolveError(f"github:{spec}: no stable tag found")
version = chosen.lstrip("vV")
# Always the refs/tags spelling: a repository with both a tag and a branch
# of the same name rejects the short /archive/NAME.tar.gz form.
url = f"https://github.com/{owner}/{repo}/archive/refs/tags/{chosen}.tar.gz"
data, digest = fetch_and_hash(url)
source = url.replace(f"/{chosen}.tar.gz", "/${version}.tar.gz") \
if chosen == version else url
meta = {}
try:
meta = http_json(f"{GITHUB_API}/repos/{owner}/{repo}")
except ResolveError:
pass
summary = (meta.get("description") or f"{repo}").strip().rstrip(".")
phases = detect_c_build(data)
return {
"name": repo.lower(),
"version": version,
"description": f"{repo.lower()}: {summary}",
"arch": "",
"source": source,
"hash": digest,
"depend": ["filesystem"],
"builddep": phases[1],
"phases": phases[0],
}
def resolve_url(spec: str) -> dict:
url = spec
data, digest = fetch_and_hash(url)
leaf = url.rsplit("/", 1)[-1]
match = re.match(r"^(?P<name>[A-Za-z0-9_+.-]+?)[-_](?P<version>[0-9][0-9A-Za-z.]*)"
r"(?:\.orig)?\.(?:tar\.(?:gz|xz|bz2|zst)|tgz|txz|tbz2|zip)$", leaf)
if not match:
raise ResolveError(f"url:{url}: cannot infer name and version from '{leaf}'")
name = match.group("name").lower()
version = match.group("version").rstrip(".")
source = url.replace(version, "${version}", 1) if version in url else url
phases = detect_c_build(data)
return {
"name": name,
"version": version,
"description": f"{name}: TODO write a one-line description",
"arch": "",
"source": source,
"hash": digest,
"depend": ["filesystem"],
"builddep": phases[1],
"phases": phases[0],
}
def detect_c_build(data: bytes) -> tuple[list[tuple[str, str]], list[str]]:
"""Pick build phases by looking at the archive's top-level files."""
top: set[str] = set()
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf:
for member in tf.getmembers()[:4000]:
parts = member.name.split("/")
if len(parts) == 2 and parts[1]:
top.add(parts[1])
except (tarfile.TarError, OSError, EOFError):
pass
if "meson.build" in top:
return ([
("configure", 'meson setup build --prefix=/usr --libdir=lib64 '
'--buildtype=release'),
("build", 'meson compile -C build -j "$MAKEJOBS"'),
("install", 'DESTDIR="$PKG" meson install -C build'),
], ["meson", "ninja", "pkgconf"])
if "CMakeLists.txt" in top:
return ([
("configure", 'cmake -S . -B build -G Ninja -DCMAKE_INSTALL_PREFIX=/usr '
'-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_LIBDIR=lib64'),
("build", 'cmake --build build --parallel "$MAKEJOBS"'),
("install", 'DESTDIR="$PKG" cmake --install build'),
], ["cmake", "ninja", "pkgconf"])
if "configure" in top:
return ([
("configure", './configure --prefix=/usr --libdir=/usr/lib64 --disable-static'),
("build", 'make -j"$MAKEJOBS"'),
("install", 'make install DESTDIR="$PKG"'),
], ["pkgconf"])
if "configure.ac" in top or "configure.in" in top or "autogen.sh" in top:
return ([
("prepare", './autogen.sh || autoreconf -fi'),
("configure", './configure --prefix=/usr --libdir=/usr/lib64 --disable-static'),
("build", 'make -j"$MAKEJOBS"'),
("install", 'make install DESTDIR="$PKG"'),
], ["autoconf", "automake", "libtool", "pkgconf"])
if "Cargo.toml" in top:
return ([
("build", 'cargo build --release --locked'),
("install", 'TODO install the built binaries into "$PKG"'),
], ["rust"])
if "go.mod" in top:
return ([
("build", 'go build -trimpath -ldflags "-s -w" ./...'),
("install", 'TODO install the built binaries into "$PKG"'),
], ["go"])
if "Makefile" in top or "GNUmakefile" in top:
return ([
("build", 'make -j"$MAKEJOBS"'),
("install", 'make install PREFIX=/usr DESTDIR="$PKG"'),
], [])
return ([
("install", 'TODO write the install phase'),
], [])
RESOLVERS = {
"pypi": resolve_pypi,
"crate": resolve_crate,
"cpan": resolve_cpan,
"github": resolve_github,
"url": resolve_url,
"https": lambda s: resolve_url("https:" + s),
"http": lambda s: resolve_url("http:" + s),
}
def resolve(spec: str) -> dict:
if ":" not in spec:
raise ResolveError(f"'{spec}' must be SCHEME:VALUE (pypi:, cpan:, crate:, github:, url:)")
scheme, value = spec.split(":", 1)
scheme = scheme.lower()
if scheme not in RESOLVERS:
raise ResolveError(f"unknown scheme '{scheme}' in '{spec}'")
return RESOLVERS[scheme](value)
def render(rec: dict) -> str:
lines = [
f"name {rec['name']}",
f"version {rec['version']}",
"release 1",
]
if rec.get("arch"):
lines.append(f"arch {rec['arch']}")
lines.append(f"description {rec['description']}")
lines.append("")
lines.append(f"source {rec['source']}")
lines.append(f"hash sha256:{rec['hash']}")
lines.append("")
for dep in rec["depend"]:
lines.append(f"depend {dep}")
for dep in rec.get("builddep", []):
lines.append(f"builddep {dep}")
if rec["depend"] or rec.get("builddep"):
lines.append("")
for key, command in rec["phases"]:
lines.append(f"{key:<11} {command}")
return "\n".join(lines) + "\n"
def default_category(rec: dict, override: str | None) -> str:
if override:
return override
name = rec["name"]
if name.startswith("python-"):
return "python"
if name.startswith("perl-"):
return "perl"
return "libs"
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
prog="new-recipe",
description="Draft SPS recipes from upstream metadata.")
ap.add_argument("specs", nargs="*", metavar="SPEC",
help="pypi:NAME, cpan:Module::Name, crate:NAME, "
"github:owner/repo, or url:URL")
ap.add_argument("--tree", help="recipe tree to write into")
ap.add_argument("--category", help="category directory (default: by kind)")
ap.add_argument("--from-file", help="read one SPEC per line from this file")
ap.add_argument("--jobs", type=int, default=8, help="parallel resolutions")
ap.add_argument("--force", action="store_true",
help="overwrite an existing recipe")
ap.add_argument("--print", dest="print_only", action="store_true",
help="write nothing; print the drafted recipes")
args = ap.parse_args(argv)
specs = list(args.specs)
if args.from_file:
for line in Path(args.from_file).read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
specs.append(line)
if not specs:
ap.error("no specs given")
if not args.print_only and not args.tree:
ap.error("--tree is required unless --print is used")
tree = Path(args.tree) if args.tree else None
if tree is not None and not tree.is_dir():
print(f"new-recipe: not a directory: {tree}", file=sys.stderr)
return 2
existing: set[str] = set()
if tree is not None:
for path in tree.rglob("recipe"):
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith("name"):
parts = line.split(None, 1)
if len(parts) == 2:
existing.add(parts[1].strip())
break
written = 0
skipped = 0
failed = 0
def work(spec: str):
try:
return spec, resolve(spec), None
except ResolveError as exc:
return spec, None, str(exc)
except Exception as exc: # noqa: BLE001
return spec, None, f"{type(exc).__name__}: {exc}"
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool:
for spec, rec, error in pool.map(work, specs):
if error:
print(f"new-recipe: {spec}: {error}", file=sys.stderr)
failed += 1
continue
text = render(rec)
if args.print_only:
print(f"# ---- {spec} ----")
print(text)
written += 1
continue
if rec["name"] in existing and not args.force:
print(f"new-recipe: {rec['name']} already exists; skipping",
file=sys.stderr)
skipped += 1
continue
dest = tree / default_category(rec, args.category) / rec["name"]
dest.mkdir(parents=True, exist_ok=True)
(dest / "recipe").write_text(text, encoding="utf-8")
existing.add(rec["name"])
print(f"new-recipe: wrote {dest / 'recipe'}")
written += 1
print(f"\n{written} drafted, {skipped} skipped, {failed} failed",
file=sys.stderr)
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())