#!/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())