Files
distro/src/recipe.py
T
Marvin Friedrich afb13bb8ad build_deps
2026-05-30 19:18:25 +02:00

259 lines
8.1 KiB
Python

import importlib
import importlib.util
import pkgutil
from dataclasses import dataclass
from functools import cache
from pathlib import Path, PurePosixPath
from typing import Any, Callable
from src import source as src_mod
from src.layout import Layout
from src.source import Subpackage, Tarball, subpackage, tarball, git
PHASE_NAMES = ("prepare", "configure", "build", "install")
LIB_DIR = Path(__file__).with_name("lib")
@dataclass
class Recipe:
name: str
kind: str # "target" | "host"
dir: Path # recipe directory or parent (for pure form)
pure: bool
version: str
revision: int
description: str
license: str
url: str
maintainer: str
sources: dict[str | None, Tarball | src_mod.Git] # key=None for single source
host_deps: tuple[str, ...]
build_deps: tuple[str, ...]
deps: tuple[str, ...]
subpackages: tuple[Subpackage, ...]
phases: dict[str, Callable[[Any], None]]
enabled: bool
@property
def key(self) -> str:
return f"host:{self.name}" if self.kind == "host" else self.name
@property
def patches_dir(self) -> Path | None:
if self.pure:
return None
p = self.dir / "patches"
return p if p.is_dir() else None
@property
def files_dir(self) -> Path | None:
if self.pure:
return None
p = self.dir / "files"
return p if p.is_dir() else None
@property
def outputs(self) -> list[str]:
if self.kind == "host":
return [self.name]
return [self.name, *(s.name for s in self.subpackages)]
# Import everything in lib/ for recipes
@cache
def _lib_symbols() -> dict:
ns = {}
if not LIB_DIR.is_dir():
return ns
for info in sorted(pkgutil.iter_modules([str(LIB_DIR)]), key=lambda i: i.name):
if info.ispkg:
continue
mod = importlib.import_module(f"src.lib.{info.name}")
names = getattr(mod, "__all__", None)
if names is None:
names = [n for n in mod.__dict__ if not n.startswith("_")]
for n in names:
ns[n] = getattr(mod, n)
return ns
# Symbols which are injected into the recipe
def _builtins(profile: dict) -> dict:
return {
**_lib_symbols(),
"tarball": tarball,
"git": git,
"subpackage": subpackage,
"path": PurePosixPath,
"profile": profile,
}
def _load_module(path: Path, ns: dict):
spec = importlib.util.spec_from_file_location(
f"orchid_recipe_{path.stem}_{id(path)}", path
)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {path}")
mod = importlib.util.module_from_spec(spec)
mod.__dict__.update(ns)
spec.loader.exec_module(mod)
return mod
def _plain_field(mod, recipe_name: str, field_name: str) -> str:
value = getattr(mod, field_name, "")
if value is None:
return ""
if not isinstance(value, str):
raise TypeError(f"{recipe_name}: '{field_name}' must be a string")
return value
def _load_one(
name: str, kind: str, recipe_file: Path, recipe_dir: Path, pure: bool, profile: dict
) -> Recipe | None:
mod = _load_module(recipe_file, _builtins(profile))
version = getattr(mod, "version", None)
if not isinstance(version, str) or not version:
raise ValueError(f"{name}: 'version' (str) required")
revision = int(getattr(mod, "revision", 1))
if hasattr(mod, "metadata"):
raise TypeError(
f"{name}: use plain description/license/url/maintainer fields, not metadata = meta(...)"
)
description = _plain_field(mod, name, "description")
license = _plain_field(mod, name, "license")
url = _plain_field(mod, name, "url")
maintainer = _plain_field(mod, name, "maintainer")
single = getattr(mod, "source", None)
multi = getattr(mod, "sources", None)
if single is not None and multi is not None:
raise ValueError(f"{name}: define either 'source' or 'sources', not both")
sources = {}
if single is not None:
if not isinstance(single, (Tarball, src_mod.Git)):
raise TypeError(f"{name}: 'source' must be tarball()/git()")
sources[None] = single
elif multi is not None:
if not isinstance(multi, dict) or not multi:
raise TypeError(f"{name}: 'sources' must be a non-empty dict")
multi_items = list(multi.items())
for k, v in multi_items:
if not isinstance(k, str) or not k:
raise TypeError(f"{name}: 'sources' keys must be non-empty strings")
if not isinstance(v, (Tarball, src_mod.Git)):
raise TypeError(f"{name}: source {k!r} must be tarball()/git()")
if len(multi_items) == 1:
sources[None] = multi_items[0][1]
else:
for k, v in multi_items:
sources[k] = v
else:
raise ValueError(f"{name}: 'source' or 'sources' required")
if pure:
for s in sources.values():
if s.patches:
raise ValueError(
f"{name}: pure-form recipe cannot declare patches; convert to {name}/recipe.py + patches/"
)
host_deps = tuple(getattr(mod, "host_deps", ()) or ())
build_deps = tuple(getattr(mod, "build_deps", ()) or ())
deps = tuple(getattr(mod, "deps", ()) or ())
subs = tuple(getattr(mod, "subpackages", ()) or ())
for s in subs:
if not isinstance(s, Subpackage):
raise TypeError(
f"{name}: subpackages entries must be subpackage(...) values"
)
if kind == "host" and subs:
raise ValueError(f"{name}: host recipes do not support subpackages")
phases: dict[str, Callable] = {}
for pn in PHASE_NAMES:
fn = getattr(mod, pn, None)
if fn is not None:
if not callable(fn):
raise TypeError(f"{name}: '{pn}' must be a function")
phases[pn] = fn
if kind == "target" and "build" not in phases and "install" not in phases:
# Purely declarative pkgs are unusual but allowed
pass
enabled = bool(getattr(mod, "build_if", True))
return Recipe(
name=name,
kind=kind,
dir=recipe_dir,
pure=pure,
version=version,
revision=revision,
description=description,
license=license,
url=url,
maintainer=maintainer,
sources=sources,
host_deps=host_deps,
build_deps=build_deps,
deps=deps,
subpackages=subs,
phases=phases,
enabled=enabled,
)
def _discover(root: Path, kind: str, profile: dict) -> dict[str, Recipe]:
out: dict[str, Recipe] = {}
if not root.is_dir():
return out
for entry in sorted(root.iterdir()):
if entry.name.startswith(".") or entry.name.startswith("_"):
continue
if entry.is_file() and entry.suffix == ".py":
name = entry.stem
r = _load_one(name, kind, entry, entry.parent, pure=True, profile=profile)
elif entry.is_dir():
rf = entry / "recipe.py"
if not rf.is_file():
continue
r = _load_one(entry.name, kind, rf, entry, pure=False, profile=profile)
else:
continue
if r is None:
continue
if r.name in out:
raise ValueError(f"duplicate {kind} recipe: {r.name}")
out[r.name] = r
return out
@dataclass
class RecipeSet:
target: dict[str, Recipe]
host: dict[str, Recipe]
def get(self, key: str) -> Recipe:
if key.startswith("host:"):
n = key[5:]
if n not in self.host:
raise KeyError(f"host recipe not found: {n}")
return self.host[n]
if key not in self.target:
raise KeyError(f"recipe not found: {key}")
return self.target[key]
def all(self) -> list[Recipe]:
return [*self.target.values(), *self.host.values()]
def load_recipes(layout: Layout, profile: dict) -> RecipeSet:
target = _discover(layout.recipes_dir, "target", profile)
host = _discover(layout.host_recipes_dir, "host", profile)
return RecipeSet(target=target, host=host)