Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · file

tools/new-recipe


Python

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())