*: Switch to python

This commit is contained in:
2026-05-26 03:06:26 +02:00
parent 2e6704516a
commit b6e18c474e
62 changed files with 15663 additions and 3441 deletions
+258
View File
@@ -0,0 +1,258 @@
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, ...]
deps: tuple[str, ...]
run_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 ())
deps = tuple(getattr(mod, "deps", ()) or ())
run_deps = tuple(getattr(mod, "run_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,
deps=deps,
run_deps=run_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)