forked from roots/phyllomeos
Replace the hardcoded TEMPLATES dict and the ksflatten-relative wrapper with a single data-driven pipeline: - recipe_templates.yaml becomes the single source of truth with three sections: base (always included), choices (exactly-one per category, the invariant pykickstart cannot check) and features (additive flags or one-of values) - generate_recipe.py is a pure YAML consumer: it resolves ingredients, renders dishes via pykickstart in-process (no ksflatten binary, no %include path munging), lints the invariants pykickstart cannot check (exactly-one per choice category, known keys, existing fragments, unique filenames) and validates every generated dish - dish names are derived by a generic rule with canonical category order from the templates, independent of YAML key order; no committed generated files means no name burn-in - recipes/ and dishes/ become gitignored build products; the 68 tracked generated files (already out of sync with the manifest) are removed - delete dead ingredients (section-data/, validation/, initial-setup/ desktop/, live/hypervisor.ks, rpmfusion-nonfree.ks), the stale all-ingredients.cfg (replaced by 'make inventory') and the bin/generate-recipe and bin/ksflatten-relative wrappers - Makefile: single 'make all' step plus lint, validate, inventory, test - add 16 pytest tests covering expansion, selection, naming, lint and flatten/validate round-trips
349 lines
13 KiB
Python
349 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Recipe and dish generator for Phyllome OS.
|
|
|
|
Reads recipes_manifest.yaml (the variant matrix) and recipe_templates.yaml
|
|
(the ingredient wiring) to:
|
|
|
|
1. compose kickstart "recipes" (%include lists) under recipes/,
|
|
2. flatten them into standalone "dishes" under dishes/ using pykickstart
|
|
in-process (no external ksflatten binary), and
|
|
3. lint the invariants pykickstart cannot check (exactly-one per choice
|
|
category, known keys, existing fragments, unique filenames) and
|
|
validate every generated dish.
|
|
|
|
Everything is derived from YAML; ingredient names, order and filenames are
|
|
pure functions of the manifest and templates.
|
|
"""
|
|
|
|
import argparse
|
|
import itertools
|
|
import os
|
|
import sys
|
|
|
|
import yaml
|
|
|
|
from pykickstart.version import DEVEL, returnClassForVersion
|
|
from pykickstart.parser import KickstartParser
|
|
|
|
HEADER = """# __ ____ ____ _____
|
|
# ____ / /_ __ __/ / /___ ____ ___ ___ / __ \\/ ___/
|
|
# / __ \\/ __ \\/ / / / / / __ \\/ __ `__ \\/ _ \\ / / / /\\__ \\
|
|
# / /_/ / / / / /_/ / / / /_/ / / / / / / __/ / /_/ /___/ /
|
|
# / .___/_/ /_/\\__, /_/_/\\____/_/ /_/ /_/\\___/ \\____//____/
|
|
# /_/ /____/
|
|
|
|
"""
|
|
|
|
|
|
def load_yaml(path):
|
|
with open(path) as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
def expand_variants(variant_config):
|
|
"""Expand a variant config into its cartesian product.
|
|
|
|
Keys holding a list of values expand to one variant per combination;
|
|
scalar and boolean keys are fixed across every combination.
|
|
"""
|
|
keys = variant_config.keys()
|
|
values = []
|
|
for key in keys:
|
|
val = variant_config[key]
|
|
if isinstance(val, list):
|
|
values.append([(key, v) for v in val])
|
|
else:
|
|
values.append([(key, val)])
|
|
combos = itertools.product(*values)
|
|
return [dict(combo) for combo in combos]
|
|
|
|
|
|
def default_choice(choices):
|
|
"""Return the first declared value of a choices category."""
|
|
return next(iter(choices))
|
|
|
|
|
|
def collect_fragments(templates, variant):
|
|
"""Resolve a variant to the ordered list of ingredient fragments.
|
|
|
|
Returns (fragments, problems) where problems is a list of human-readable
|
|
lint messages. Choices default to their first declared value when the
|
|
variant omits them.
|
|
"""
|
|
fragments = list(templates.get('base', []))
|
|
problems = []
|
|
|
|
for category, mapping in templates.get('choices', {}).items():
|
|
value = variant.get(category)
|
|
if value is None:
|
|
value = default_choice(mapping)
|
|
path = mapping.get(value)
|
|
if path is None:
|
|
problems.append(
|
|
"choice '%s': unknown value '%s' (expected one of %s)"
|
|
% (category, value, ', '.join(sorted(mapping))))
|
|
else:
|
|
fragments.append(path)
|
|
|
|
for feature, body in templates.get('features', {}).items():
|
|
value = variant.get(feature)
|
|
if value is None or isinstance(value, bool) and not value:
|
|
continue
|
|
if isinstance(body, str):
|
|
fragments.append(body)
|
|
else:
|
|
rendered = body.get(value)
|
|
if rendered is None:
|
|
problems.append(
|
|
"feature '%s': unknown value '%s' (expected one of %s)"
|
|
% (feature, value, ', '.join(sorted(body))))
|
|
elif isinstance(rendered, list):
|
|
fragments.extend(rendered)
|
|
else:
|
|
fragments.append(rendered)
|
|
|
|
fragments = list(dict.fromkeys(fragments))
|
|
return fragments, problems
|
|
|
|
|
|
def render_filename(templates, group_name, variant):
|
|
"""Build a deterministic, collision-free dish name for a variant.
|
|
|
|
Tokens follow the canonical category order from the templates (choices
|
|
first, then features), independent of the key order the manifest uses, so
|
|
reordering the manifest -- or YAML tools that sort keys -- never renames
|
|
dishes. Boolean True renders the key itself, boolean False is omitted,
|
|
other values render str(value). The group name prefixes every dish,
|
|
making names unique across groups.
|
|
"""
|
|
order = list(templates.get('choices', {})) + list(templates.get('features', {}))
|
|
tokens = []
|
|
for key in order:
|
|
if key not in variant:
|
|
continue
|
|
value = variant[key]
|
|
if isinstance(value, bool):
|
|
if value:
|
|
tokens.append(key)
|
|
else:
|
|
tokens.append(str(value))
|
|
return "%s_%s.cfg" % (group_name, '_'.join(tokens))
|
|
|
|
|
|
def render_recipe(fragments):
|
|
"""Render a recipe (%include list) for the given ingredient fragments."""
|
|
includes = '\n'.join(f'%include ../ingredients/{slug}' for slug in fragments)
|
|
return HEADER + includes + '\n'
|
|
|
|
|
|
def lint_manifest(manifest, templates, ingredients_root):
|
|
"""Return a list of lint problems across the whole manifest."""
|
|
problems = []
|
|
group_names = set()
|
|
|
|
for group in manifest.get('recipes', []):
|
|
group_name = group.get('name', 'unknown')
|
|
if group_name in group_names:
|
|
problems.append("duplicate group name '%s'" % group_name)
|
|
group_names.add(group_name)
|
|
|
|
for config in group.get('variants', []):
|
|
for variant in expand_variants(config):
|
|
known = set(templates.get('choices')) | set(templates.get('features'))
|
|
for key in variant:
|
|
if key not in known:
|
|
problems.append(
|
|
"unknown variant key '%s' (group '%s')" % (key, group_name))
|
|
_, probs = collect_fragments(templates, variant)
|
|
problems.extend(probs)
|
|
name = render_filename(templates, group_name, variant)
|
|
problems.extend(check_fragments_exist(templates, variant, ingredients_root))
|
|
|
|
filenames = []
|
|
for group in manifest.get('recipes', []):
|
|
group_name = group.get('name', 'unknown')
|
|
for config in group.get('variants', []):
|
|
for variant in expand_variants(config):
|
|
filenames.append(render_filename(templates, group_name, variant))
|
|
seen = set()
|
|
for name in filenames:
|
|
if name in seen:
|
|
problems.append("duplicate dish name '%s'" % name)
|
|
seen.add(name)
|
|
|
|
return problems
|
|
|
|
|
|
def check_fragments_exist(templates, variant, ingredients_root):
|
|
fragments, _ = collect_fragments(templates, variant)
|
|
problems = []
|
|
for slug in fragments:
|
|
path = os.path.join(ingredients_root, slug)
|
|
if not os.path.isfile(path):
|
|
problems.append("missing ingredient fragment '%s'" % slug)
|
|
return problems
|
|
|
|
|
|
def flatten_recipe(recipe_path):
|
|
"""Flatten a recipe (%include list) into a standalone kickstart string."""
|
|
handler = returnClassForVersion(DEVEL)()
|
|
parser = KickstartParser(handler)
|
|
parser.readKickstart(recipe_path)
|
|
return str(handler)
|
|
|
|
|
|
def validate_dish(dish_path):
|
|
"""Return (ok, error) for a standalone kickstart dish."""
|
|
try:
|
|
handler = returnClassForVersion(DEVEL)()
|
|
parser = KickstartParser(handler)
|
|
parser.readKickstart(dish_path)
|
|
return True, None
|
|
except Exception as exc: # noqa: BLE001 - report any parse failure
|
|
return False, str(exc)
|
|
|
|
|
|
def generate(manifest_path, templates_path, recipes_dir, dishes_dir,
|
|
ingredients_dir, do_generate=True, do_lint=True, do_validate=True):
|
|
"""Full cooking pipeline. Returns an exit code (0 on success)."""
|
|
manifest = load_yaml(manifest_path)
|
|
templates = load_yaml(templates_path)
|
|
ingredients_root = os.path.join(os.path.dirname(os.path.abspath(manifest_path)),
|
|
ingredients_dir)
|
|
|
|
if do_lint:
|
|
problems = lint_manifest(manifest, templates, ingredients_root)
|
|
else:
|
|
problems = []
|
|
|
|
failures = []
|
|
generated = []
|
|
|
|
recipe_dir = os.path.abspath(recipes_dir)
|
|
dish_dir = os.path.abspath(dishes_dir)
|
|
if do_generate:
|
|
os.makedirs(recipe_dir, exist_ok=True)
|
|
os.makedirs(dish_dir, exist_ok=True)
|
|
|
|
for group in manifest.get('recipes', []):
|
|
group_name = group.get('name', 'unknown')
|
|
for config in group.get('variants', []):
|
|
for variant in expand_variants(config):
|
|
fragments, probs = collect_fragments(templates, variant)
|
|
problems.extend(probs)
|
|
name = render_filename(templates, group_name, variant)
|
|
generated.append(name)
|
|
if not do_generate:
|
|
continue
|
|
|
|
recipe_path = os.path.join(recipe_dir, name)
|
|
with open(recipe_path, 'w') as f:
|
|
f.write(render_recipe(fragments))
|
|
|
|
dish_path = os.path.join(dish_dir, name)
|
|
try:
|
|
dish = flatten_recipe(recipe_path)
|
|
with open(dish_path, 'w') as f:
|
|
f.write(dish)
|
|
except Exception as exc: # noqa: BLE001
|
|
failures.append('%s: flatten failed: %s' % (name, exc))
|
|
|
|
if do_validate and do_generate:
|
|
for name in generated:
|
|
dish_path = os.path.join(dish_dir, name)
|
|
if not os.path.isfile(dish_path):
|
|
continue
|
|
ok, error = validate_dish(dish_path)
|
|
if not ok:
|
|
problems.append('%s: invalid dish: %s' % (name, error))
|
|
|
|
if do_generate:
|
|
print("Generated %d recipes and %d dishes" % (len(generated), len(generated)))
|
|
if problems:
|
|
for problem in problems:
|
|
print("lint: %s" % problem, file=sys.stderr)
|
|
if failures:
|
|
for failure in failures:
|
|
print(failure, file=sys.stderr)
|
|
return 1 if (problems or failures) else 0
|
|
|
|
|
|
def print_inventory(templates_path, ingredients_dir):
|
|
templates = load_yaml(templates_path)
|
|
root = os.path.abspath(ingredients_dir)
|
|
out = []
|
|
out.append("Ingredient inventory for Phyllome OS (from %s)" % templates_path)
|
|
out.append("=" * 60)
|
|
out.append("\nBase (always included, in order):")
|
|
for slug in templates.get('base', []):
|
|
out.append(" %-40s %s" % (slug, os.path.exists(os.path.join(root, slug)) and "ok" or "MISSING"))
|
|
out.append("\nChoices (exactly one per category):")
|
|
for category, mapping in templates.get('choices', {}).items():
|
|
out.append(" %s:" % category)
|
|
for value, slug in mapping.items():
|
|
out.append(" %-12s %-30s %s" % (value, slug,
|
|
os.path.exists(os.path.join(root, slug)) and "ok" or "MISSING"))
|
|
out.append("\nFeatures (additive):")
|
|
for feature, body in templates.get('features', {}).items():
|
|
out.append(" %s:" % feature)
|
|
if isinstance(body, str):
|
|
out.append(" (flag) %-30s %s" % (body,
|
|
os.path.exists(os.path.join(root, body)) and "ok" or "MISSING"))
|
|
else:
|
|
for value, slugs in body.items():
|
|
if isinstance(slugs, list):
|
|
for slug in slugs:
|
|
out.append(" %-12s %-30s %s" % (value, slug,
|
|
os.path.exists(os.path.join(root, slug)) and "ok" or "MISSING"))
|
|
else:
|
|
out.append(" %-12s %-30s %s" % (value, slugs,
|
|
os.path.exists(os.path.join(root, slugs)) and "ok" or "MISSING"))
|
|
print('\n'.join(out))
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
description='Generate Phyllome OS recipes and dishes')
|
|
parser.add_argument('--manifest', default='recipes_manifest.yaml',
|
|
help='recipe variant manifest (default: recipes_manifest.yaml)')
|
|
parser.add_argument('--templates', default='recipe_templates.yaml',
|
|
help='ingredient templates (default: recipe_templates.yaml)')
|
|
parser.add_argument('--recipes-dir', default='recipes',
|
|
help='output directory for recipe include lists (default: recipes)')
|
|
parser.add_argument('--dishes-dir', default='dishes',
|
|
help='output directory for flattened dishes (default: dishes)')
|
|
parser.add_argument('--ingredients-dir', default='ingredients',
|
|
help='ingredient fragments directory (default: ingredients)')
|
|
parser.add_argument('--no-generate', action='store_true',
|
|
help='do not write recipes or dishes (lint/validate only)')
|
|
parser.add_argument('--no-lint', action='store_true',
|
|
help='skip manifest linting')
|
|
parser.add_argument('--no-validate', action='store_true',
|
|
help='skip dish validation')
|
|
parser.add_argument('--inventory', action='store_true',
|
|
help='print the ingredient inventory from templates and exit')
|
|
args = parser.parse_args(argv)
|
|
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
os.chdir(script_dir)
|
|
|
|
manifest_path = args.manifest if os.path.isabs(args.manifest) \
|
|
else os.path.join(script_dir, args.manifest)
|
|
templates_path = args.templates if os.path.isabs(args.templates) \
|
|
else os.path.join(script_dir, args.templates)
|
|
|
|
if args.inventory:
|
|
print_inventory(templates_path, args.ingredients_dir)
|
|
return 0
|
|
|
|
return generate(
|
|
manifest_path, templates_path, args.recipes_dir, args.dishes_dir,
|
|
args.ingredients_dir,
|
|
do_generate=not args.no_generate,
|
|
do_lint=not args.no_lint,
|
|
do_validate=not args.no_validate)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main()) |