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)