Skip to content

config

napt.config

Configuration loading and management for NAPT.

This module provides tools for loading, merging, and validating YAML-based configuration files with a layered approach:

  • Organization-wide defaults (defaults/org.yaml)
  • Vendor-specific defaults (defaults/vendors/{Vendor}.yaml)
  • Recipe-specific configuration (recipes/{Vendor}/{app}.yaml)

The loader performs deep merging where dicts are merged recursively and lists/scalars are replaced (last wins). Relative paths are resolved against the recipe file location for relocatability.

Example

Basic usage:

from pathlib import Path
from napt.config import load_effective_config

config = load_effective_config(Path("recipes/Google/chrome.yaml"))
app = config.get("app")
print(app["name"])  # "Google Chrome"

load_effective_config

load_effective_config(recipe_path: Path, *, vendor: str | None = None) -> dict[str, Any]

Loads and merges the effective configuration for a recipe.

Performs the following operations:

  1. Read recipe YAML
  2. Find defaults root by scanning upwards for defaults/org.yaml
  3. Load org defaults (required if defaults root exists)
  4. Determine vendor (param vendor > folder name > recipe contents)
  5. Load vendor defaults if present
  6. Merge: org -> vendor -> recipe (dicts deep-merge, lists replace)
  7. Resolve known relative paths (relative to the recipe directory)
  8. Inject dynamic fields (AppScriptDate = today if absent)

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe YAML file.

required
vendor str | None

Optional vendor name override. If not provided, vendor is detected from the folder name or recipe contents.

None

Returns:

Type Description
dict[str, Any]

A merged configuration dict ready for downstream processors. If no defaults were found in the tree, the recipe is returned as-is (with path resolution and injection).

Raises:

Type Description
ConfigError

On YAML parse errors, empty files, invalid structure, or if the recipe file is missing.

Source code in napt/config/loader.py
def load_effective_config(
    recipe_path: Path,
    *,
    vendor: str | None = None,
) -> dict[str, Any]:
    """Loads and merges the effective configuration for a recipe.

    Performs the following operations:

    1. Read recipe YAML
    2. Find defaults root by scanning upwards for defaults/org.yaml
    3. Load org defaults (required if defaults root exists)
    4. Determine vendor (param vendor > folder name > recipe contents)
    5. Load vendor defaults if present
    6. Merge: org -> vendor -> recipe (dicts deep-merge, lists replace)
    7. Resolve known relative paths (relative to the recipe directory)
    8. Inject dynamic fields (AppScriptDate = today if absent)

    Args:
        recipe_path: Path to the recipe YAML file.
        vendor: Optional vendor name override. If not provided, vendor is detected
            from the folder name or recipe contents.

    Returns:
        A merged configuration dict ready for downstream processors. If no defaults
            were found in the tree, the recipe is returned as-is (with path
            resolution and injection).

    Raises:
        ConfigError: On YAML parse errors, empty files, invalid structure, or if the recipe file is missing.
    """
    from napt.logging import get_global_logger

    logger = get_global_logger()
    recipe_path = recipe_path.resolve()
    recipe_dir = recipe_path.parent

    logger.verbose("CONFIG", f"Loading recipe: {recipe_path}")

    # 1) Read recipe
    recipe_obj = _load_yaml_file(recipe_path)
    if not isinstance(recipe_obj, dict):
        raise ConfigError(f"top-level YAML must be a mapping (dict): {recipe_path}")

    # 2) Find defaults root
    defaults_root = _find_defaults_root(recipe_dir)
    if defaults_root:
        logger.verbose("CONFIG", f"Found defaults root: {defaults_root}")

    # Start with code defaults (always present baseline)
    merged = copy.deepcopy(DEFAULT_CONFIG)
    layers_merged = 1  # Code defaults count as first layer

    org_defaults_path: Path | None = None
    vendor_name: str | None = vendor

    if defaults_root:
        # 3) Load org defaults
        org_defaults_path = defaults_root / "org.yaml"
        if org_defaults_path.exists():
            logger.verbose(
                "CONFIG",
                f"Loading: {org_defaults_path.relative_to(defaults_root.parent)}",
            )
            org_defaults = _load_yaml_file(org_defaults_path)
            if isinstance(org_defaults, dict):
                logger.debug("CONFIG", "--- Content from org.yaml ---")
                _print_yaml_content(org_defaults)
                merged = _deep_merge_dicts(merged, org_defaults)
                layers_merged += 1

        # 4) Determine vendor
        if vendor_name is None:
            vendor_name = _detect_vendor(recipe_path, recipe_obj)

        if vendor_name:
            logger.verbose("CONFIG", f"Detected vendor: {vendor_name}")

        # 5) Load vendor defaults if present
        if vendor_name:
            candidate = defaults_root / "vendors" / f"{vendor_name}.yaml"
            if candidate.exists():
                logger.verbose(
                    "CONFIG", f"Loading: {candidate.relative_to(defaults_root.parent)}"
                )
                vendor_defaults = _load_yaml_file(candidate)
                if isinstance(vendor_defaults, dict):
                    logger.debug("CONFIG", f"--- Content from {vendor_name}.yaml ---")
                    _print_yaml_content(vendor_defaults)
                    merged = _deep_merge_dicts(merged, vendor_defaults)
                    layers_merged += 1

    # Show recipe content
    logger.verbose("CONFIG", f"Loading: {recipe_path.name}")
    logger.debug("CONFIG", f"--- Content from {recipe_path.name} ---")
    _print_yaml_content(recipe_obj)

    # 6) Merge recipe on top
    merged = _deep_merge_dicts(merged, recipe_obj)
    layers_merged += 1

    logger.verbose("CONFIG", f"Deep merging {layers_merged} layer(s)")
    # Show final config structure
    top_level_keys = list(merged.keys())
    logger.verbose(
        "CONFIG",
        (
            f"Final config has {len(top_level_keys)} top-level keys: "
            f"{', '.join(top_level_keys)}"
        ),
    )
    # Show the complete merged configuration in debug mode
    logger.debug("CONFIG", "--- Final Merged Configuration ---")
    _print_yaml_content(merged)

    # 7) Resolve relative paths (branding paths relative to defaults_root)
    _resolve_known_paths(merged, recipe_dir, defaults_root)

    # 8) Inject dynamic values (e.g., AppScriptDate)
    _inject_dynamic_values(merged)

    # Optionally attach context for debugging (commented out by default)
    # merged["_load_context"] = LoadContext(
    #     recipe_path=recipe_path,
    #     defaults_root=defaults_root,
    #     vendor_name=vendor_name,
    #     org_defaults_path=org_defaults_path,
    #     vendor_defaults_path=vendor_defaults_path,
    # ).__dict__

    return merged