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, name, id, discovery)
  • apiVersion is supported
  • discovery.strategy exists and is registered
  • Strategy-specific configuration is valid
  • intune.detection fields are valid (types, values, unknown field warnings)
  • psadt.app_vars only contains user-settable keys
  • logging section fields are valid
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_config

validate_config(
    config: dict[str, Any], recipe_path: str = ""
) -> ValidationResult

Validates a merged configuration dict.

Checks required fields, types, strategy registration, and section-level validation. Used by both validate_recipe (CLI) and load_effective_config (pipeline commands) so that one set of rules applies everywhere.

Parameters:

Name Type Description Default
config dict[str, Any]

The merged configuration dictionary to validate.

required
recipe_path str

Optional path string for error context.

''

Returns:

Type Description
ValidationResult

Validation status, errors, warnings, and app count.

Source code in napt/validation.py
def validate_config(
    config: dict[str, Any],
    recipe_path: str = "",
) -> ValidationResult:
    """Validates a merged configuration dict.

    Checks required fields, types, strategy registration, and section-level
    validation. Used by both ``validate_recipe`` (CLI) and
    ``load_effective_config`` (pipeline commands) so that one set of rules
    applies everywhere.

    Args:
        config: The merged configuration dictionary to validate.
        recipe_path: Optional path string for error context.

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

    """
    logger = get_global_logger()

    errors: list[str] = []
    warnings: list[str] = []

    # Check apiVersion
    if "apiVersion" not in config:
        errors.append("Missing required field: apiVersion")
    else:
        api_version = config["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 required top-level fields: name and id
    for field in ["name", "id"]:
        if field not in config:
            errors.append(f"Missing required field: {field}")

    if "name" in config and not isinstance(config["name"], str):
        errors.append("Field 'name' must be a string")

    if "id" in config:
        if not isinstance(config["id"], str):
            errors.append("Field 'id' must be a string")
        elif not config["id"]:
            errors.append("Field 'id' cannot be empty")

    app_name = config.get("name", "unnamed")
    logger.verbose("VALIDATION", f"Validating: {app_name}")

    # Validate discovery section
    discovery = config.get("discovery")
    if discovery is None:
        errors.append("Missing required field: discovery")
    elif not isinstance(discovery, dict):
        errors.append("Field 'discovery' must be a dictionary")
    else:
        if "strategy" not in discovery:
            errors.append("discovery: Missing required field: strategy")
        else:
            strategy_name = discovery["strategy"]
            if not isinstance(strategy_name, str):
                errors.append("discovery.strategy: Must be a string")
            else:
                logger.verbose(
                    "VALIDATION",
                    f"'{app_name}' uses strategy: {strategy_name}",
                )

                # Validate strategy-specific configuration
                if strategy_name == "url_download":
                    from napt.discovery.url_download import (
                        validate_url_download_config,
                    )

                    try:
                        errors.extend(validate_url_download_config(config))
                    except Exception as err:
                        errors.append(f"Strategy validation failed: {err}")
                else:
                    try:
                        strategy = get_strategy(strategy_name)
                    except ConfigError as err:
                        errors.append(f"discovery.strategy: {err}")
                    else:
                        try:
                            errors.extend(strategy.validate_config(config))
                        except Exception as err:
                            errors.append(f"Strategy validation failed: {err}")

    # Validate optional sections
    _validate_psadt_section(config, errors)
    _validate_intune_section(config, errors, warnings)
    _validate_logging_section(config, 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=recipe_path,
    )

validate_recipe

validate_recipe(recipe_path: Path) -> ValidationResult

Loads, merges, and validates a recipe file.

Parses the YAML file, merges it through the config hierarchy, and validates the merged result. This is the entry point for napt validate.

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:
    """Loads, merges, and validates a recipe file.

    Parses the YAML file, merges it through the config hierarchy, and
    validates the merged result. This is the entry point for ``napt validate``.

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

    recipe_path_str = str(recipe_path)

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

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

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

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

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

    # Validate the parsed config dict
    return validate_config(recipe, recipe_path=recipe_path_str)