Skip to content

validation

napt.validation

Recipe validation module.

This module provides validation functions for checking recipe syntax and configuration without making network calls or downloading files. This is useful for quick feedback during recipe development and in CI/CD pipelines.

Validation Checks:

  • YAML syntax is valid
  • Required top-level fields present (apiVersion, app)
  • apiVersion is supported
  • App has required fields (name, id, source)
  • Discovery strategy exists and is registered
  • Strategy-specific configuration is valid
  • Win32 configuration fields are valid (types, values, unknown field warnings)
Example

Validate a recipe and handle results:

from pathlib import Path
from napt.validation import validate_recipe

result = validate_recipe(Path("recipes/Google/chrome.yaml"))
if result.status == "valid":
    print(f"Recipe is valid with {result.app_count} app(s)")
else:
    for error in result.errors:
        print(f"Error: {error}")

validate_recipe

validate_recipe(recipe_path: Path) -> ValidationResult

Validate a recipe file without downloading anything.

Validates recipe syntax, required fields, and configuration without making network calls.

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe YAML file to validate.

required

Returns:

Type Description
ValidationResult

Validation status, errors, warnings, and app count.

Example

Validate a recipe and check results:

from pathlib import Path

result = validate_recipe(Path("recipes/app.yaml"))
if result.status == "valid":
    print("Recipe is valid!")
else:
    for error in result.errors:
        print(f"Error: {error}")

Source code in napt/validation.py
def validate_recipe(recipe_path: Path) -> ValidationResult:
    """Validate a recipe file without downloading anything.

    Validates recipe syntax, required fields, and configuration without
    making network calls.

    Args:
        recipe_path: Path to the recipe YAML file to validate.

    Returns:
        Validation status, errors, warnings, and app count.

    Example:
        Validate a recipe and check results:
            ```python
            from pathlib import Path

            result = validate_recipe(Path("recipes/app.yaml"))
            if result.status == "valid":
                print("Recipe is valid!")
            else:
                for error in result.errors:
                    print(f"Error: {error}")
            ```

    """
    logger = get_global_logger()

    errors = []
    warnings = []
    app_count = 0

    logger.verbose("VALIDATION", f"Validating recipe: {recipe_path}")

    # Check file exists
    if not recipe_path.exists():
        errors.append(f"Recipe file not found: {recipe_path}")
        return ValidationResult(
            status="invalid",
            errors=errors,
            warnings=warnings,
            app_count=0,
            recipe_path=str(recipe_path),
        )

    # Parse YAML
    try:
        with open(recipe_path, encoding="utf-8") as f:
            recipe = yaml.safe_load(f)
    except yaml.YAMLError as err:
        errors.append(f"Invalid YAML syntax: {err}")
        return ValidationResult(
            status="invalid",
            errors=errors,
            warnings=warnings,
            app_count=0,
            recipe_path=str(recipe_path),
        )
    except Exception as err:
        errors.append(f"Failed to read recipe file: {err}")
        return ValidationResult(
            status="invalid",
            errors=errors,
            warnings=warnings,
            app_count=0,
            recipe_path=str(recipe_path),
        )

    logger.verbose("VALIDATION", "YAML syntax is valid")

    # Validate recipe is a dict
    if not isinstance(recipe, dict):
        errors.append("Recipe must be a YAML dictionary/mapping")
        return ValidationResult(
            status="invalid",
            errors=errors,
            warnings=warnings,
            app_count=0,
            recipe_path=str(recipe_path),
        )

    # Check apiVersion
    if "apiVersion" not in recipe:
        errors.append("Missing required field: apiVersion")
    else:
        api_version = recipe["apiVersion"]
        if not isinstance(api_version, str):
            errors.append("apiVersion must be a string")
        elif api_version != "napt/v1":
            warnings.append(
                f"apiVersion '{api_version}' may not be supported (expected: napt/v1)"
            )
        if not errors:
            logger.verbose("VALIDATION", f"apiVersion: {api_version}")

    # Check app field
    app = recipe.get("app")
    if not app:
        errors.append("Field 'app' is required")
        return ValidationResult(
            status="invalid",
            errors=errors,
            warnings=warnings,
            app_count=0,
            recipe_path=str(recipe_path),
        )

    if not isinstance(app, dict):
        errors.append("Field 'app' must be a dictionary")
        return ValidationResult(
            status="invalid",
            errors=errors,
            warnings=warnings,
            app_count=0,
            recipe_path=str(recipe_path),
        )

    app_prefix = "app"

    logger.verbose("VALIDATION", f"Found app: {app.get('name', 'unnamed')}")

    # Check required fields
    for field in ["name", "id", "source"]:
        if field not in app:
            errors.append(f"{app_prefix}: Missing required field: {field}")

    # Validate name
    if "name" in app and not isinstance(app["name"], str):
        errors.append(f"{app_prefix}: Field 'name' must be a string")

    # Validate id
    if "id" in app:
        if not isinstance(app["id"], str):
            errors.append(f"{app_prefix}: Field 'id' must be a string")
        elif not app["id"]:
            errors.append(f"{app_prefix}: Field 'id' cannot be empty")

    # Validate source
    if "source" not in app:
        # Already reported missing field, but continue to check other things
        pass
    else:
        source = app["source"]
        if not isinstance(source, dict):
            errors.append(f"{app_prefix}.source: Must be a dictionary")
        else:
            # Check strategy field
            if "strategy" not in source:
                errors.append(f"{app_prefix}.source: Missing required field: strategy")
            else:
                strategy_name = source["strategy"]
                if not isinstance(strategy_name, str):
                    errors.append(f"{app_prefix}.source.strategy: Must be a string")
                else:
                    logger.verbose(
                        "VALIDATION",
                        f"App '{app.get('name', 'unnamed')}' uses strategy: {strategy_name}",
                    )

                    # Check if strategy exists
                    try:
                        strategy = get_strategy(strategy_name)
                    except ConfigError as err:
                        errors.append(f"{app_prefix}.source.strategy: {err}")
                    else:
                        # Validate strategy-specific configuration
                        if hasattr(strategy, "validate_config"):
                            try:
                                config_errors = strategy.validate_config(app)
                                for error in config_errors:
                                    errors.append(f"{app_prefix}: {error}")
                            except Exception as err:
                                errors.append(
                                    f"{app_prefix}: Strategy validation failed: {err}"
                                )

    # Validate win32 configuration
    _validate_win32_config(app, app_prefix, errors, warnings)

    # Validate psadt configuration
    _validate_psadt_config(app, app_prefix, errors)

    # Validate optional top-level intune: section
    _validate_intune_config(recipe, errors, warnings)

    # Determine final status
    status = "valid" if len(errors) == 0 else "invalid"
    app_count = 1 if status == "valid" else 0

    if status == "valid":
        logger.verbose("VALIDATION", "Recipe is valid!")
    else:
        logger.verbose("VALIDATION", f"Recipe has {len(errors)} error(s)")

    return ValidationResult(
        status=status,
        errors=errors,
        warnings=warnings,
        app_count=app_count,
        recipe_path=str(recipe_path),
    )