The Nix agent derivation builds scripts/build/*.py from a fileset that does not include pm/, so importing pm.store.ALL_TARGETS there failed nix flake check with ModuleNotFoundError. Extend the local target regex with linux-(x64|arm64)-musl instead.
123 lines
5.7 KiB
Python
123 lines
5.7 KiB
Python
"""Build-time paths, not a second runtime configuration or dependency resolver."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field, fields
|
|
from pathlib import Path
|
|
import json
|
|
import re
|
|
import tomllib
|
|
|
|
|
|
RESOURCE_ENV = {
|
|
"skills": "HERMES_BUNDLED_SKILLS",
|
|
"optional-skills": "HERMES_OPTIONAL_SKILLS",
|
|
"plugins": "HERMES_BUNDLED_PLUGINS",
|
|
"locales": "HERMES_BUNDLED_LOCALES",
|
|
"optional-mcps": "HERMES_OPTIONAL_MCPS",
|
|
}
|
|
|
|
|
|
def dependency_site(environment: Path, python_version: str, target: str) -> Path:
|
|
"""Providers know the target pin; the builder's interpreter is irrelevant."""
|
|
minor = python_version.split("+")[0].rsplit(".", 1)[0]
|
|
return environment / ("Lib/site-packages" if target.startswith("win32") else
|
|
f"lib/python{minor}/site-packages")
|
|
|
|
|
|
def project_entries(project: Path) -> dict[str, str]:
|
|
entries = tomllib.loads(project.read_text(encoding="utf-8-sig"))["project"]["scripts"]
|
|
for name, entry in entries.items():
|
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name):
|
|
raise ValueError(f"invalid command name: {name}")
|
|
if not re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*", entry):
|
|
raise ValueError(f"invalid script entry: {entry}")
|
|
return entries
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentInputs:
|
|
project: Path
|
|
code: Path
|
|
repo: str
|
|
placement: str
|
|
target: str
|
|
python: Path
|
|
site_packages: Path
|
|
environment: Path
|
|
pm_runtime: Path
|
|
bin_dir: str = "bin"
|
|
tools: Path | None = None
|
|
command_dir: Path | None = None
|
|
resources: dict[str, Path] = field(default_factory=dict)
|
|
frontends: dict[str, Path] = field(default_factory=dict)
|
|
ref: str | None = None
|
|
stamp: Path | None = None
|
|
features: Path | None = None
|
|
env: dict[str, str] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> AgentInputs:
|
|
unknown = data.keys() - {f.name for f in fields(cls)}
|
|
if unknown:
|
|
raise ValueError(f"unknown agent inputs: {', '.join(sorted(unknown))}")
|
|
values = dict(data)
|
|
for key in ("project", "code", "python", "site_packages", "environment", "tools",
|
|
"pm_runtime", "command_dir", "stamp", "features"):
|
|
if values.get(key) is not None:
|
|
values[key] = Path(values[key])
|
|
for key in ("resources", "frontends"):
|
|
values[key] = {name: Path(path) for name, path in values.get(key, {}).items()}
|
|
return cls(**values)
|
|
|
|
def validate(self, out: Path) -> None:
|
|
if not re.fullmatch(r"(?:(?:linux|darwin|win32)-(?:x64|arm64)|linux-(?:x64|arm64)-musl|linux-arm64-bionic)", self.target):
|
|
raise ValueError(f"unsupported target: {self.target}")
|
|
if self.placement not in ("contained", "fixed", "references"):
|
|
raise ValueError(f"unknown placement: {self.placement}")
|
|
if Path(self.repo).is_absolute() or ".." in Path(self.repo).parts:
|
|
raise ValueError("repo must be an output-relative path")
|
|
if not re.fullmatch(r"[A-Za-z0-9_-]+", self.bin_dir):
|
|
raise ValueError("bin_dir must be one output-relative directory")
|
|
if self.resources.keys() - RESOURCE_ENV.keys():
|
|
raise ValueError("unknown resource names")
|
|
if self.frontends.keys() - {"tui", "web"}:
|
|
raise ValueError("unknown frontend names")
|
|
paths = [self.project, self.code, self.python, self.site_packages, self.environment,
|
|
self.pm_runtime, *self.resources.values(), *self.frontends.values()]
|
|
paths += [p for p in (self.tools, self.command_dir, self.stamp, self.features) if p is not None]
|
|
for path in paths:
|
|
if not path.is_absolute():
|
|
raise ValueError(f"input path must be absolute: {path}")
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"required agent input missing: {path}")
|
|
for path in (self.project, self.python):
|
|
if not path.is_file():
|
|
raise ValueError(f"expected a file: {path}")
|
|
for path in (self.code, self.site_packages, self.environment, self.pm_runtime):
|
|
if not path.is_dir():
|
|
raise ValueError(f"expected a directory: {path}")
|
|
marker = json.loads((self.pm_runtime / "pm-runtime.json").read_text(encoding="utf-8-sig"))
|
|
pm_python = (self.pm_runtime / marker["python"]).resolve()
|
|
pm_site = (self.pm_runtime / marker["sitePackages"]).resolve()
|
|
if not pm_python.is_file() or not pm_site.is_dir():
|
|
raise ValueError("prepared PM runtime is incomplete")
|
|
if self.stamp is not None:
|
|
stamp = json.loads(self.stamp.read_text(encoding="utf-8-sig"))
|
|
if stamp.get("distribution") in ("nix", "docker") and stamp.get("pmRuntime") != str(self.pm_runtime):
|
|
raise ValueError("install stamp must reference the supplied independent PM runtime")
|
|
if self.placement == "contained":
|
|
for path in (self.python, self.site_packages, self.environment, self.pm_runtime, self.tools):
|
|
if path is not None and not path.resolve().is_relative_to(out):
|
|
raise ValueError(f"contained input must be prepared inside output: {path}")
|
|
if self.placement == "references" and self.command_dir is None:
|
|
raise ValueError("referenced placement requires command_dir")
|
|
validate_frontends(self.frontends)
|
|
|
|
|
|
def validate_frontends(frontends: dict[str, Path]) -> None:
|
|
for name, path in frontends.items():
|
|
required = ("dist/entry.js", "package.json") if name == "tui" else ("index.html",)
|
|
for entry in required:
|
|
if not (path / entry).is_file():
|
|
raise FileNotFoundError(path / entry)
|