Skip to content

config

napt.config

Configuration loading and management for NAPT.

Loads, merges, and validates 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.

Modules:

Name Description
loader

The 3-layer configuration loader (load_effective_config).

defaults

Built-in default configuration and the org.yaml template.

napt.config.loader

Configuration loading and merging for NAPT.

This module implements a layered configuration system that allows NAPT to work out of the box while supporting full customization. Each layer wins over the previous, promoting DRY (Don't Repeat Yourself) principles.

Configuration Layers
  1. Code defaults (napt/config/defaults.py)
  2. Built-in defaults that ship with NAPT
  3. Always present; ensures NAPT works without any config files
  4. Provides sensible defaults for all settings

  5. Organization defaults (defaults/org.yaml)

  6. Organization-wide settings
  7. Optional; only loaded if file exists
  8. Customizes settings for your organization

  9. Vendor defaults (defaults/vendors/{Vendor}.yaml)

  10. Vendor-specific settings (e.g., Google-specific settings)
  11. Optional; only loaded if vendor is detected
  12. Wins over organization defaults

  13. Parent recipe (the file named by the recipe's parent field)

  14. Another recipe merged beneath this one
  15. Optional; a parent may not itself declare a parent
  16. Wins over vendor defaults

  17. Recipe configuration (recipes/{Vendor}/{app}.yaml)

  18. App-specific configuration
  19. Always required; defines the app itself
  20. Wins over all other layers
Merge Behavior

The loader performs deep merging with "last wins" semantics:

  • Dicts: Recursively merged (keys from overlay win over base)
  • Lists: Completely replaced (NOT appended/extended)
  • Scalars: Overwritten (strings, numbers, booleans)
Path Resolution

Relative paths in configuration are resolved against the RECIPE FILE location, making recipes relocatable and portable. A parent's relative paths resolve against the child recipe, not the parent file. Currently resolved paths:

  • psadt.brand_pack.path
  • intune.logo_path
Dynamic Injection

Some fields are injected at load time:

  • psadt.app_vars.AppScriptDate: Today's date (YYYY-MM-DD)
Error Handling
  • ConfigError: Recipe file doesn't exist, YAML parse errors, empty files, invalid structure, a missing parent, or a parent chain
  • All errors are chained with "from err" for better debugging
Note
  • Code defaults are always applied first (NAPT works without config files)
  • The loader walks upward from the recipe to find defaults/org.yaml
  • Organization and vendor defaults are optional layers
  • Vendor is detected from directory name (recipes/Google/) or recipe content
  • Paths are resolved relative to the recipe, not the working directory
  • Dynamic fields are best-effort (warnings on failure, not errors)

load_parent

load_parent(
    recipe_path: Path, recipe_obj: dict[str, Any]
) -> tuple[Path, dict[str, Any]] | None

Loads the parent recipe a recipe declares, if any.

The parent field names another recipe file relative to the declaring recipe's directory. The parent is merged beneath the declaring recipe by load_effective_config and by napt validate.

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe that may declare parent.

required
recipe_obj dict[str, Any]

The parsed recipe dictionary.

required

Returns:

Type Description
tuple[Path, dict[str, Any]] | None

The resolved parent path and its parsed contents, or None when the recipe declares no parent.

Raises:

Type Description
ConfigError

When parent is not a non-empty string, the parent file is missing or not a mapping, or the parent itself declares a parent (chains are not supported).

Source code in napt/config/loader.py
def load_parent(
    recipe_path: Path, recipe_obj: dict[str, Any]
) -> tuple[Path, dict[str, Any]] | None:
    """Loads the parent recipe a recipe declares, if any.

    The ``parent`` field names another recipe file relative to the
    declaring recipe's directory. The parent is merged beneath the
    declaring recipe by
    [load_effective_config][napt.config.loader.load_effective_config] and by
    ``napt validate``.

    Args:
        recipe_path: Path to the recipe that may declare ``parent``.
        recipe_obj: The parsed recipe dictionary.

    Returns:
        The resolved parent path and its parsed contents, or None when the
            recipe declares no parent.

    Raises:
        ConfigError: When ``parent`` is not a non-empty string, the parent
            file is missing or not a mapping, or the parent itself declares
            a parent (chains are not supported).
    """
    parent_ref = recipe_obj.get("parent")
    if parent_ref is None:
        return None
    if not isinstance(parent_ref, str) or not parent_ref.strip():
        raise ConfigError(f"parent must be a non-empty string: {recipe_path}")

    parent_path = (recipe_path.resolve().parent / parent_ref).resolve()
    if not parent_path.is_file():
        raise ConfigError(
            f"Parent recipe not found: {parent_path} (declared in {recipe_path})"
        )

    parent_obj = _load_yaml_file(parent_path)
    if not isinstance(parent_obj, dict):
        raise ConfigError(f"top-level YAML must be a mapping (dict): {parent_path}")
    if "parent" in parent_obj:
        raise ConfigError(
            f"Parent chains are not supported: {parent_path} declares its own "
            f"parent (used as parent by {recipe_path})"
        )
    return parent_path, parent_obj

merge_parent

merge_parent(
    recipe_path: Path, recipe_obj: dict[str, Any]
) -> tuple[dict[str, Any], Path | None]

Merges a recipe over its parent without the other configuration layers.

Used by napt validate, which checks a recipe's own schema rather than the fully merged configuration. The recipe's parent field survives the merge so schema validation can see it.

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe that may declare parent.

required
recipe_obj dict[str, Any]

The parsed recipe dictionary.

required

Returns:

Type Description
tuple[dict[str, Any], Path | None]

The merged dictionary and the parent path, or the recipe unchanged and None when it declares no parent.

Raises:

Type Description
ConfigError
Source code in napt/config/loader.py
def merge_parent(
    recipe_path: Path, recipe_obj: dict[str, Any]
) -> tuple[dict[str, Any], Path | None]:
    """Merges a recipe over its parent without the other configuration layers.

    Used by ``napt validate``, which checks a recipe's own schema rather than
    the fully merged configuration. The recipe's ``parent`` field survives
    the merge so schema validation can see it.

    Args:
        recipe_path: Path to the recipe that may declare ``parent``.
        recipe_obj: The parsed recipe dictionary.

    Returns:
        The merged dictionary and the parent path, or the recipe unchanged
            and None when it declares no parent.

    Raises:
        ConfigError: See [load_parent][napt.config.loader.load_parent].
    """
    loaded = load_parent(recipe_path, recipe_obj)
    if loaded is None:
        return recipe_obj, None
    parent_path, parent_obj = loaded
    return _deep_merge_dicts(parent_obj, recipe_obj), parent_path

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 and its parent recipe, if it declares one
  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 -> parent -> recipe (dicts deep-merge, lists replace)
  7. Resolve known relative paths (relative to the recipe directory)
  8. Inject dynamic fields (AppScriptDate = today if absent)

The returned dict does not carry the parent field; the parent's contents are already merged in.

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe YAML file.

required
vendor str | None

Optional vendor name. 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, a missing recipe or parent file, or a parent chain.

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 and its parent recipe, if it declares one
    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 -> parent -> recipe (dicts deep-merge, lists
       replace)
    7. Resolve known relative paths (relative to the recipe directory)
    8. Inject dynamic fields (AppScriptDate = today if absent)

    The returned dict does not carry the ``parent`` field; the parent's
    contents are already merged in.

    Args:
        recipe_path: Path to the recipe YAML file.
        vendor: Optional vendor name. 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, a
            missing recipe or parent file, or a parent chain.
    """
    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 and its parent, if any
    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}")

    parent_path: Path | None = None
    parent_obj: dict[str, Any] | None = None
    loaded_parent = load_parent(recipe_path, recipe_obj)
    if loaded_parent is not None:
        parent_path, parent_obj = loaded_parent

    # 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)
    provenance: dict[str, Any] = {}
    layers_merged = 1  # Code defaults count as first layer

    # Initialize provenance: all DEFAULT_CONFIG keys start as "code_default"
    def _init_provenance(cfg: dict[str, Any], prov: dict[str, Any]) -> None:
        for k, v in cfg.items():
            if isinstance(v, dict):
                sub = prov.setdefault(k, {})
                _init_provenance(v, sub)
            else:
                prov[k] = "code_default"

    _init_provenance(DEFAULT_CONFIG, provenance)

    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,
                    provenance=provenance,
                    layer_name="org_yaml",
                )
                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,
                        provenance=provenance,
                        layer_name="vendor_yaml",
                    )
                    layers_merged += 1

    # 6) Merge the parent beneath the recipe, then the recipe on top
    if parent_path is not None and parent_obj is not None:
        logger.verbose("CONFIG", f"Loading parent: {parent_path}")
        logger.debug("CONFIG", f"--- Content from {parent_path.name} ---")
        _print_yaml_content(parent_obj)
        merged = _deep_merge_dicts(
            merged, parent_obj, provenance=provenance, layer_name="parent"
        )
        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)

    merged = _deep_merge_dicts(
        merged, recipe_obj, provenance=provenance, layer_name="recipe"
    )
    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, RequireAdmin)
    _inject_dynamic_values(merged, provenance)

    # Store provenance for downstream consumers
    merged["_provenance"] = provenance

    # 9) Validate the merged config (errors raise, warnings are logged)
    from napt.validation import validate_config

    result = validate_config(merged, recipe_path=str(recipe_path))
    if result.errors:
        where = f" (parent: {parent_path})" if parent_path is not None else ""
        raise ConfigError(f"Invalid configuration{where}: {'; '.join(result.errors)}")
    for warning in result.warnings:
        logger.warning("CONFIG", warning)

    # The parent's contents are merged in; the pointer itself is not config.
    merged.pop("parent", None)
    provenance.pop("parent", None)

    return merged

napt.config.defaults

Default configuration values for NAPT.

This module provides the baseline configuration that ships with NAPT. These defaults are always applied first, then overridden by organization defaults (org.yaml), vendor defaults, and finally recipe-specific settings.

The configuration hierarchy is
  1. Code defaults (this module) - always present
  2. Organization defaults (defaults/org.yaml) - optional overrides
  3. Vendor defaults (defaults/vendors/{Vendor}.yaml) - optional overrides
  4. Recipe configuration - required, app-specific settings

This design ensures that NAPT works out of the box without requiring any configuration files, while still allowing full customization when needed.

Note

Authentication for 'napt upload' requires no config file. Developers run 'napt auth login' once; CI/CD pipelines set AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_CLIENT_SECRET, or use OIDC federation.