test grouping
This commit is contained in:
222
packages/node/meshnet_node/recipe_manifest.py
Normal file
222
packages/node/meshnet_node/recipe_manifest.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Local, versioned recipe manifest.
|
||||
|
||||
A recipe is *data*: a named, versioned set of execution parameters handed to the
|
||||
model backend. It carries no model- or vendor-specific code path — a recipe is
|
||||
only ever valid once its own real forward has succeeded on this node
|
||||
(see :mod:`meshnet_node.capability`).
|
||||
|
||||
The manifest ships with the node release. ``schema_version`` describes the file
|
||||
layout this reader understands; ``catalogue_version`` identifies the recipe set
|
||||
itself so a tracker can reason about which catalogue a node validated against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
# Layout of recipes.json understood by this reader. Bump when the file shape changes.
|
||||
RECIPE_SCHEMA_VERSION = 1
|
||||
|
||||
DEFAULT_RECIPE_ID = "baseline"
|
||||
|
||||
_MANIFEST_RESOURCE = "recipes.json"
|
||||
|
||||
|
||||
class RecipeManifestError(ValueError):
|
||||
"""Raised when a recipe manifest is missing, malformed, or unsupported.
|
||||
|
||||
The message is operator-facing: it names the source and the fix, and never
|
||||
echoes raw file content back (a manifest may sit next to secrets in a
|
||||
misconfigured deployment).
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Recipe:
|
||||
"""One named, versioned execution recipe."""
|
||||
|
||||
id: str
|
||||
version: str
|
||||
backend_id: str
|
||||
description: str = ""
|
||||
params: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"version": self.version,
|
||||
"backend_id": self.backend_id,
|
||||
"description": self.description,
|
||||
"params": dict(self.params),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecipeManifest:
|
||||
"""A parsed, validated recipe catalogue."""
|
||||
|
||||
schema_version: int
|
||||
catalogue_version: str
|
||||
recipes: tuple[Recipe, ...]
|
||||
source: str = "<memory>"
|
||||
|
||||
def get(self, recipe_id: str) -> Recipe | None:
|
||||
for recipe in self.recipes:
|
||||
if recipe.id == recipe_id:
|
||||
return recipe
|
||||
return None
|
||||
|
||||
def require(self, recipe_id: str) -> Recipe:
|
||||
"""Return the named recipe, or raise listing what this catalogue offers."""
|
||||
recipe = self.get(recipe_id)
|
||||
if recipe is None:
|
||||
available = ", ".join(r.id for r in self.recipes) or "(none)"
|
||||
raise RecipeManifestError(
|
||||
f"unknown recipe {recipe_id!r} in {self.source}; "
|
||||
f"available recipes: {available}"
|
||||
)
|
||||
return recipe
|
||||
|
||||
@property
|
||||
def ids(self) -> tuple[str, ...]:
|
||||
return tuple(r.id for r in self.recipes)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"catalogue_version": self.catalogue_version,
|
||||
"recipes": [r.to_dict() for r in self.recipes],
|
||||
}
|
||||
|
||||
|
||||
def _require_mapping(value: Any, what: str, source: str) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise RecipeManifestError(
|
||||
f"{what} in {source} must be a JSON object, got {type(value).__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_text(value: Any, what: str, source: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RecipeManifestError(
|
||||
f"{what} in {source} must be a non-empty string"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_recipe(raw: Any, index: int, source: str) -> Recipe:
|
||||
entry = _require_mapping(raw, f"recipes[{index}]", source)
|
||||
recipe_id = _require_text(entry.get("id"), f"recipes[{index}].id", source)
|
||||
version = _require_text(
|
||||
entry.get("version"), f"recipes[{recipe_id}].version", source
|
||||
)
|
||||
backend_id = _require_text(
|
||||
entry.get("backend_id"), f"recipes[{recipe_id}].backend_id", source
|
||||
)
|
||||
params = entry.get("params", {})
|
||||
if params is None:
|
||||
params = {}
|
||||
_require_mapping(params, f"recipes[{recipe_id}].params", source)
|
||||
description = entry.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
raise RecipeManifestError(
|
||||
f"recipes[{recipe_id}].description in {source} must be a string"
|
||||
)
|
||||
return Recipe(
|
||||
id=recipe_id,
|
||||
version=version,
|
||||
backend_id=backend_id,
|
||||
description=description,
|
||||
params=dict(params),
|
||||
)
|
||||
|
||||
|
||||
def parse_recipe_manifest(data: Any, source: str = "<memory>") -> RecipeManifest:
|
||||
"""Validate an already-decoded manifest document."""
|
||||
doc = _require_mapping(data, "manifest root", source)
|
||||
|
||||
if "schema_version" not in doc:
|
||||
raise RecipeManifestError(
|
||||
f"{source} is missing 'schema_version'; "
|
||||
f"this node reads recipe schema version {RECIPE_SCHEMA_VERSION}"
|
||||
)
|
||||
schema_version = doc["schema_version"]
|
||||
if not isinstance(schema_version, int) or isinstance(schema_version, bool):
|
||||
raise RecipeManifestError(
|
||||
f"'schema_version' in {source} must be an integer, "
|
||||
f"got {type(schema_version).__name__}"
|
||||
)
|
||||
if schema_version != RECIPE_SCHEMA_VERSION:
|
||||
raise RecipeManifestError(
|
||||
f"{source} declares recipe schema version {schema_version}, "
|
||||
f"but this node reads version {RECIPE_SCHEMA_VERSION}; "
|
||||
"upgrade the node or use a manifest for the supported version"
|
||||
)
|
||||
|
||||
catalogue_version = _require_text(
|
||||
doc.get("catalogue_version"), "'catalogue_version'", source
|
||||
)
|
||||
|
||||
raw_recipes = doc.get("recipes")
|
||||
if not isinstance(raw_recipes, list) or not raw_recipes:
|
||||
raise RecipeManifestError(
|
||||
f"'recipes' in {source} must be a non-empty JSON array"
|
||||
)
|
||||
|
||||
recipes: list[Recipe] = []
|
||||
seen: set[str] = set()
|
||||
for index, raw in enumerate(raw_recipes):
|
||||
recipe = _parse_recipe(raw, index, source)
|
||||
if recipe.id in seen:
|
||||
raise RecipeManifestError(
|
||||
f"duplicate recipe id {recipe.id!r} in {source}; recipe ids must be unique"
|
||||
)
|
||||
seen.add(recipe.id)
|
||||
recipes.append(recipe)
|
||||
|
||||
return RecipeManifest(
|
||||
schema_version=schema_version,
|
||||
catalogue_version=catalogue_version,
|
||||
recipes=tuple(recipes),
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def load_recipe_manifest(path: Path | None = None) -> RecipeManifest:
|
||||
"""Load the packaged manifest, or one at ``path``.
|
||||
|
||||
No network access and no remote catalogue: P0 recipes ship with the node.
|
||||
"""
|
||||
if path is None:
|
||||
source = f"packaged {_MANIFEST_RESOURCE}"
|
||||
try:
|
||||
raw = files("meshnet_node").joinpath(_MANIFEST_RESOURCE).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
except (OSError, FileNotFoundError, ModuleNotFoundError) as exc:
|
||||
raise RecipeManifestError(
|
||||
f"{source} is missing from this node installation "
|
||||
f"({type(exc).__name__}); reinstall the node package"
|
||||
) from exc
|
||||
else:
|
||||
source = str(path)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise RecipeManifestError(
|
||||
f"cannot read recipe manifest {source}: {exc.strerror or exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RecipeManifestError(
|
||||
f"{source} is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}"
|
||||
) from exc
|
||||
|
||||
return parse_recipe_manifest(data, source=source)
|
||||
Reference in New Issue
Block a user