*: Switch to python
This commit is contained in:
+258
@@ -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)
|
||||
Reference in New Issue
Block a user