259 lines
8.1 KiB
Python
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)
|