Files
phyllomeos/cook/generate_recipe.py
T
Lukas Greve ae6eef4045 refactor(cook): data-driven recipe generation with in-process flatten and lint
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
2026-08-30 12:09:42 +02:00

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())