docs: Add comprehensive documentation to recipe generator

Add detailed docstrings and inline comments to all recipe generator modules explaining classes, functions, and complex logic in plain English.

Files updated:
- recipe-generator/recipe_generator.py: Module, class, and method docs
- recipe-generator/cli.py: CLI modes, arguments, and workflow docs
- recipe-generator/validators.py: Three-layer validation architecture docs
- recipe-generator/manifest.py: Manifest processing and variant expansion docs
- recipe-generator/generate_recipe.py: Entry point documentation
This commit is contained in:
Lukas Greve
2026-03-27 11:07:16 +01:00
parent 31955a8983
commit 0a3c2b14d8
5 changed files with 1052 additions and 113 deletions
+226 -29
View File
@@ -1,4 +1,22 @@
"""CLI entry point for recipe generator."""
"""CLI entry point for recipe generator.
This module handles the command-line interface for the recipe generator. It:
1. Parses command-line arguments and options
2. Selects the appropriate mode (validation, batch generation, single generation)
3. Sets up generators and validators with proper configuration
4. Orchestrates the workflow based on user input
5. Handles output and exit codes for integration with CI systems
The CLI supports three main modes:
- Single generation: Generate one recipe with specific options
- Batch generation: Generate many recipes from a manifest file
- Validation mode: Check existing recipes for errors
Exit codes:
- 0: Success
- 1: Validation errors or generation failed
- 2: Invalid arguments or missing files
"""
from __future__ import annotations
@@ -20,13 +38,51 @@ from validators import (
def main() -> None:
"""Main entry point for recipe generator CLI."""
"""Main entry point for recipe generator CLI.
This is the primary function that gets called when running the script.
It handles argument parsing, mode selection, and workflow orchestration.
Command-line arguments:
--ingredients (-i): Directory containing ingredient fragments (default: parent/ingredients)
--templates (-t): Path to templates YAML file (default: ./recipe_templates.yaml)
Batch mode flags:
--manifest (-m): Path to manifest YAML for batch generation
--output-dir (-d): Output directory for generated recipes (default: ./recipes)
--dry-run (-n): Show what would be generated without writing files
Single generation flags:
--type (-T): Recipe type to generate (e.g., virtual-desktop)
--output (-o): Output file path for single generation
Recipe parameters:
--version (-v): Fedora version (43 or rawhide, default: 43)
--desktop: Desktop environment (gnome or labwc, default: gnome)
--storage: Storage type (standard or encrypted, default: standard)
--security: Security mode (secure or devel, default: secure)
--cpu: CPU optimization (generic, amdcpu, or intelcpu)
--gpu: GPU passthrough (none or intelgpu, default: none)
Validation mode:
--validate (-V): Validate existing recipe files
--strict: Treat warnings as errors (CI mode)
Modes:
1. Validation mode (--validate): Check recipe files without generating new ones
2. Batch generation mode (--manifest): Generate many recipes from a manifest
3. Single generation mode (--type): Generate one recipe with specified options
4. Help mode: No arguments or --help shows usage information
"""
# Set up argument parser with descriptive help text
parser = argparse.ArgumentParser(
description='Generate Phyllome OS kickstart recipes from templates',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Global options
# Calculate paths relative to this script's location
# SCRIPTS_DIR points to recipe-generator/
# PROJECT_ROOT points to phyllomeos/
SCRIPTS_DIR = Path(__file__).resolve().parent # noqa: N806 - Constant
PROJECT_ROOT = SCRIPTS_DIR.parent # noqa: N806 - Constant
@@ -37,7 +93,7 @@ def main() -> None:
type=Path, default=SCRIPTS_DIR / 'recipe_templates.yaml',
help='Templates YAML file (default: ./recipe_templates.yaml)')
# Batch mode
# Batch mode options
parser.add_argument('--manifest', '-m',
type=Path, help='Manifest YAML for batch generation')
parser.add_argument('--output-dir', '-d',
@@ -47,13 +103,13 @@ def main() -> None:
action='store_true',
help='Show what would be generated without writing files')
# Single generation mode
# Single generation mode options
parser.add_argument('--type', '-T',
help='Recipe type (e.g., virtual-desktop)')
parser.add_argument('--output', '-o',
type=Path, help='Output file (single generation)')
# Recipe parameters
# Recipe customization parameters (default to safe/common values)
parser.add_argument('--version', '-v',
choices=['43', 'rawhide'], default='43',
help='Fedora version (default: 43)')
@@ -86,58 +142,87 @@ def main() -> None:
action='store_true',
help='Treat warnings as errors (CI mode)')
# Parse arguments from command line
args = parser.parse_args()
# Initialize generator
# Initialize the recipe generator with loaded templates
generator = RecipeGenerator(args.templates)
# Initialize validators
# Initialize validators - they'll be used throughout the workflow
template_validator = TemplateValidator(generator.project_root)
content_validator = ContentValidator(generator.project_root)
semantic_validator = SemanticValidator()
# Validation mode
# === VALIDATION MODE ===
# If --validate is specified, skip generation and just check existing recipes
if args.validate:
all_issues = validate_recipes(args.validate, content_validator, semantic_validator, args.strict)
handle_validation_results(all_issues, args.strict)
return
# Batch generation mode
# === BATCH GENERATION MODE ===
# If --manifest is specified, generate many recipes from a manifest file
if args.manifest:
generate_from_manifest(args, generator)
return
# Single generation mode
# === SINGLE GENERATION MODE ===
# If --type is specified, generate one recipe with the given options
if args.type:
generate_single(args, generator)
return
# No mode specified, show help
# === NO MODE SPECIFIED ===
# Show help and exit with error
parser.print_help()
sys.exit(1)
def validate_recipes(recipe_paths: List[str], content_validator: ContentValidator,
semantic_validator: SemanticValidator, strict: bool) -> List:
"""Validate multiple recipes."""
"""Validate multiple recipes from file paths.
This function iterates through a list of recipe file paths, validates each one
for both content issues (duplicates, missing ingredients) and semantic issues
(kickstart syntax errors).
The validation process for each file:
1. Read the file content
2. Run ContentValidator to check ingredient existence and duplicates
3. Try to extract the Fedora version from content or filename
4. Run SemanticValidator (pykickstart parser) to check kickstart syntax
5. Collect all issues found
Args:
recipe_paths: List of file paths to validate
content_validator: ContentValidator instance for ingredient checks
semantic_validator: SemanticValidator instance for kickstart parsing
strict: If True, treat warnings as errors (exit with 1)
Returns:
List of (path, issues) tuples for files that have issues
Example: [('recipes/gnome.cfg', ['ERROR: Missing ingredient', 'Warning: ...'])]
"""
all_issues = []
for recipe_path in recipe_paths:
try:
# Validate content
# Validate content - check for duplicates and missing ingredients
content = Path(recipe_path).read_text(encoding='utf-8')
issues = content_validator.validate(content)
# Extract version and validate semantically
# Extract version from content or filename for semantic validation
filename = Path(recipe_path).stem
version = extract_version(content, filename)
if version:
semantic_issues = semantic_validator.validate(content, version)
issues.extend(semantic_issues)
else:
# Could not determine version, can't do semantic validation
issues.append("Warning: Could not determine version, "
"skipping semantic validation")
if issues:
# Only track files that have issues
all_issues.append((recipe_path, issues))
except FileNotFoundError:
print(f"Error: Recipe not found: {recipe_path}", file=sys.stderr)
@@ -147,13 +232,37 @@ def validate_recipes(recipe_paths: List[str], content_validator: ContentValidato
def extract_version(content: str, filename: str) -> str | None:
"""Extract Fedora version from recipe content or filename."""
"""Extract Fedora version from recipe content or filename.
This function tries multiple heuristics to determine which Fedora version
a recipe is targeting. It checks the filename first, then falls back to
searching the content for version-specific patterns.
The method checks for:
1. Version in filename: patterns like _43_, -rawhide.cfg, _43.cfg
2. Version in content: repository paths like 'core-fedora-repo-43'
This is useful for batch operations where the version might not be
explicitly stated but can be inferred from naming conventions or content.
Args:
content: The content of the recipe file (as a string)
filename: Just the filename without path
Returns:
Version string ('43' or 'rawhide') if found, None otherwise
"""
import re
# Try to extract from filename first using regex
# Matches patterns like _43, -43, _rawhide, -rawhide, etc.
# The lookahead for .cfg|.yaml|$ ensures we don't match 43 in '431'
filename_match = re.search(r'(?:_|-)(43|rawhide)(?:_|-|.cfg|.yaml|$)', filename)
if filename_match:
return filename_match.group(1)
# Fall back to content inspection
# Check for Fedora repository paths that contain version info
for line in content.split('\n'):
if 'core-fedora-repo-43' in line:
return '43'
@@ -168,52 +277,105 @@ def extract_version(content: str, filename: str) -> str | None:
def handle_validation_results(all_issues: List, strict: bool) -> None:
"""Handle validation results and exit with appropriate code."""
"""Handle validation results and exit with appropriate code.
This function formats and displays validation results to the user,
then exits with the appropriate code based on the results and strict mode.
The output format:
- Group issues by file
- Show errors first (separate from warnings)
- Show summary totals at the end
Exit codes:
- 0: All files passed validation
- 1: Any issues found (or warnings in strict mode)
- 2: Error during processing
Args:
all_issues: List of (path, issues) tuples from validate_recipes()
strict: If True, warnings are treated as errors
"""
if all_issues:
# Display issues for each file that has problems
for path, issues in all_issues:
print(f"\n{path}:", file=sys.stderr)
# Count errors and warnings separately
error_count = sum(1 for i in issues if 'ERROR' in i or 'error' in i.lower())
warning_count = sum(1 for i in issues if 'Warning' in i or 'warning' in i.lower())
# Display errors (if any)
if error_count > 0:
for issue in issues:
if 'ERROR' in issue or 'error' in issue.lower():
print(f" {issue}", file=sys.stderr)
# Display warnings (if any)
if warning_count > 0:
for issue in issues:
if 'Warning' in issue or 'warning' in issue.lower():
print(f" {issue}", file=sys.stderr)
# Handle case where no issues match expected patterns
if error_count == 0 and warning_count == 0:
print(f" No issues found (file exists)", file=sys.stderr)
# Print summary
print(f"\nSummary:", file=sys.stderr)
print(f" - {len(all_issues)} recipe(s) checked", file=sys.stderr)
# Calculate totals across all files
total_errors = sum(len([i for i in issues if 'ERROR' in i or 'error' in i.lower()])
for _, issues in all_issues)
total_warnings = sum(len([i for i in issues if 'Warning' in i or 'warning' in i.lower()])
for _, issues in all_issues)
print(f" - {total_errors} error(s), {total_warnings} warning(s)", file=sys.stderr)
# In strict mode, warnings are treated as errors
if strict and total_warnings > 0:
print("\nStrict mode: Warnings treated as errors", file=sys.stderr)
sys.exit(1)
if total_errors > 0:
sys.exit(1)
if all_issues:
# Exit with error if any issues found
if total_errors > 0 or all_issues:
sys.exit(1)
else:
# All recipes validated successfully
print("All recipes validated successfully")
sys.exit(0)
def generate_from_manifest(args: argparse.Namespace, generator: RecipeGenerator) -> None:
"""Generate recipes from manifest."""
"""Generate recipes in batch from a manifest file.
This function handles batch generation mode. It:
1. Loads and parses the manifest YAML file
2. Validates the manifest structure
3. Iterates through each recipe configuration in the manifest
4. Expands any variants with list values (cartesian product)
5. Generates each variant and writes to output files
6. Optionally runs validation or dry-run checks
The manifest YAML format:
recipes:
- name: virtual-desktop
variants:
- version: 43
desktop: gnome
storage: encrypted
- version: ["43", "rawhide"]
security: secure
The list-valued variants (like version) get expanded into all combinations.
Args:
args: Parsed command-line arguments containing paths and settings
generator: Pre-configured RecipeGenerator instance
"""
manifest_path = args.manifest
try:
# Load manifest YAML file
with open(manifest_path, encoding='utf-8') as f:
manifest = yaml.safe_load(f)
except FileNotFoundError:
@@ -223,7 +385,7 @@ def generate_from_manifest(args: argparse.Namespace, generator: RecipeGenerator)
print(f"Error: Invalid YAML in manifest: {e}", file=sys.stderr)
sys.exit(2)
# Validate manifest
# Validate manifest structure before processing
manifest_processor = ManifestProcessor(generator.project_root)
errors = manifest_processor.validate(manifest)
if errors:
@@ -232,32 +394,36 @@ def generate_from_manifest(args: argparse.Namespace, generator: RecipeGenerator)
print(f" - {error}", file=sys.stderr)
sys.exit(1)
# Track seen filenames to avoid overwriting duplicates
# Generate all recipes
# Generate all recipes from the manifest
for recipe_config in manifest.get('recipes', []):
recipe_type = recipe_config['name']
if recipe_type not in generator.templates:
print(f"Error: Unknown recipe type in manifest: {recipe_type}", file=sys.stderr)
sys.exit(1)
# Get variants for this recipe type
variants = recipe_config.get('variants', [])
# Expand list-valued variants (e.g., version: ["43", "rawhide"])
variants = generator.expand_variants(variants)
for variant in variants:
# Extract version and other modifiers from variant
version = variant['version']
modifiers = {k: v for k, v in variant.items() if k not in ['name', 'version']}
variant_subname = variant.get('name', '')
# Add variant name as modifier if present
if variant_subname:
modifiers['variant_type'] = variant_subname
modifiers['variant_subname'] = variant_subname
# Generate the recipe content
content = generator.generate(recipe_type, version, **modifiers)
# Optional validation on generated content
if args.validate and not args.dry_run:
content_validator = ContentValidator(generator.project_root)
issues = content_validator.validate(content)
semantic_validator = SemanticValidator()
semantic_issues = semantic_validator.validate(content, version)
all_issues = issues + semantic_issues
if all_issues:
@@ -266,20 +432,45 @@ def generate_from_manifest(args: argparse.Namespace, generator: RecipeGenerator)
print(f" - {issue}", file=sys.stderr)
sys.exit(1)
# Generate output filename based on recipe parameters
filename = generator.generate_filename(recipe_type, version, **modifiers)
output_path = args.output_dir / filename
# Handle dry-run mode
if args.dry_run:
print(f"Would generate: {output_path}")
else:
# Actually write the file
print(f"Generating: {output_path}")
with open(output_path, 'w', encoding='utf-8') as f:
f.write(content)
def generate_single(args: argparse.Namespace, generator: RecipeGenerator) -> None:
"""Generate a single recipe."""
"""Generate a single recipe with the specified parameters.
This function handles single recipe generation mode. It:
1. Constructs a modifiers dictionary from command-line arguments
2. Only includes non-default values (e.g., only adds 'encrypted' if storage is encrypted)
3. Generates the recipe content
4. Optionally validates the content
5. Outputs to a file or stdout based on arguments
Command-line parameter to modifier mapping:
--type -> recipe_type (required)
--version -> version (43 or rawhide)
--desktop -> desktop (gnome or labwc)
--storage -> storage (standard or encrypted)
--security -> security (secure or devel)
--cpu -> cpu (generic, amdcpu, or intelcpu)
--gpu -> gpu (none or intelgpu)
Args:
args: Parsed command-line arguments
generator: Pre-configured RecipeGenerator instance
"""
# Build modifiers dictionary from command-line arguments
# Only include non-default values to keep filenames clean
modifiers = {
'variant_type': 'desktop',
'desktop': args.desktop if args.desktop else None,
@@ -288,10 +479,13 @@ def generate_single(args: argparse.Namespace, generator: RecipeGenerator) -> Non
'cpu': args.cpu if args.cpu and args.cpu != 'generic' else None,
'gpu': args.gpu if args.gpu and args.gpu != 'none' else None,
}
# Remove Nones from modifiers
modifiers = {k: v for k, v in modifiers.items() if v is not None}
# Generate the recipe
content = generator.generate(args.type, args.version, **modifiers)
# Optional validation
if args.validate:
content_validator = ContentValidator(generator.project_root)
issues = content_validator.validate(content)
@@ -306,7 +500,9 @@ def generate_single(args: argparse.Namespace, generator: RecipeGenerator) -> Non
else:
print("Validation passed")
# Output the recipe
if args.output:
# Write to file
if args.dry_run:
print(f"Would write to: {args.output}")
else:
@@ -314,4 +510,5 @@ def generate_single(args: argparse.Namespace, generator: RecipeGenerator) -> Non
f.write(content)
print(f"Generated: {args.output}")
else:
# Print to stdout
print(content)