Source code for configcraft.inherit

# -*- coding: utf-8 -*-

"""
Hierarchical Configuration Inheritance Pattern

This module provides a DRY (Don't Repeat Yourself) solution for configuration 
management by implementing inheritance patterns similar to object-oriented 
programming, but for JSON-like data structures.

**Problem It Solves**

When managing configurations for multiple environments (dev, staging, prod), 
you often need to repeat common settings across environments. This leads to 
duplication and maintenance overhead.

**Solution**

Use a special ``_defaults`` section to define default values that automatically
inherit to other sections, while allowing environment-specific overrides.

**How It Works**

The ``_defaults`` section contains JSON path patterns that specify where default
values should be applied. Values are only set if they don't already exist 
(no overwriting).

**Basic Example**

Input configuration::

    {
        "_defaults": {
            "*.username": "root",       # Apply to all environments
            "*.memory": 2               # Default memory allocation
        },
        "dev": {
            "password": "dev123"        # Dev-specific setting
        },
        "prod": {
            "password": "prod456",      # Prod-specific setting
            "memory": 8                 # Override default memory
        }
    }

After applying inheritance, becomes::

    {
        "dev": {
            "username": "root",         # Inherited from _defaults
            "password": "dev123",       # Original value
            "memory": 2                 # Inherited from _defaults
        },
        "prod": {
            "username": "root",         # Inherited from _defaults
            "password": "prod456",      # Original value
            "memory": 8                 # Override (not replaced)
        }
    }

**JSON Path Patterns**

- ``*.field``: Apply to all top-level keys (except _defaults)
- ``env.field``: Apply to specific environment
- ``*.db.*.port``: Apply to nested structures with wildcards
- ``env.services.port``: Apply to specific nested path

**Key Features**

- Non-destructive: Existing values are never overwritten
- Recursive: Supports nested _defaults sections for fine-grained control
- Flexible: Works with dictionaries and lists of dictionaries
- Order-aware: Evaluation order matters for overlapping patterns
"""

import typing as T

DEFAULTS = "_defaults"
"""
Special key used to define default values that can be inherited by other configuration sections.
"""

_error_tpl = (
    "node at JSON path {_prefix!r} is not a dict or list of dict! "
    "cannot set node value '{prefix}.{key}' = ...!"
)


[docs] def make_type_error(prefix: str, key: str) -> TypeError: """ Create a descriptive TypeError when trying to set a value on incompatible data types. This helper creates user-friendly error messages when the inheritance process encounters data that isn't a dict or list of dicts, which are the only structures that support key assignment. :param prefix: The JSON path prefix where the error occurred :param key: The key we were trying to set :raises TypeError: with descriptive message about the invalid operation """ if prefix == "": _prefix = "." else: _prefix = prefix return TypeError(_error_tpl.format(_prefix=_prefix, prefix=prefix, key=key))
[docs] def inherit_value( path: str, value: T.Any, data: T.Union[T.Dict[str, T.Any], T.List[T.Dict[str, T.Any]]], _prefix: T.Optional[str] = None, ) -> None: """ Apply a default value to a JSON path pattern, preserving existing values. This is the core inheritance mechanism that implements setdefault-like behavior for nested configuration structures. Like dict.setdefault(), it only sets values where keys don't already exist, never overwriting existing configuration. **What it does** - Follows JSON path patterns like ``"*.username"`` or ``"dev.database.port"`` - Sets values only where they're missing (non-destructive) - Handles wildcards (*) to apply to multiple targets - Works with nested dicts and lists of dicts **Examples** - Path ``"*.memory"`` -> Sets ``memory=2`` in all top-level environments - Path ``"dev.db.port"`` -> Sets ``port=5432`` only in ``dev.db`` - Path ``"*.servers.*.cpu"`` -> Sets ``cpu=1`` in all servers across all environments :param path: JSON path pattern (e.g., ``"*.username"``, ``"dev.db.port"``) :param value: The default value to set :param data: Configuration dict/list to modify in-place :param _prefix: Internal recursion parameter (do not use) :raises ValueError: If path ends with "*" (incomplete path) :raises TypeError: If trying to set values on incompatible data types :return: None .. important:: The input param ``data`` will be modified in-place. If you want to keep the original data, do this before calling this function: .. code-block:: python import copy new_data = copy.deepcopy(data) inherit_value(path, value, new_data) """ # print(f"{path = }, {value = }, {_prefix = }") # for debug only if path.endswith("*"): raise ValueError("json path cannot ends with *!") if _prefix is None: _prefix = "" parts = path.split(".") if len(parts) == 1: if isinstance(data, dict): data.setdefault(parts[0], value) elif isinstance(data, list): for item in data: if not isinstance(item, dict): raise make_type_error(_prefix, parts[0]) item.setdefault(parts[0], value) else: raise make_type_error(_prefix, parts[0]) return key = parts[0] if key == "*": for k, v in data.items(): if k != DEFAULTS: inherit_value( path=".".join(parts[1:]), value=value, data=v, _prefix=f"{_prefix}.{key}", ) else: if isinstance(data, dict): inherit_value( path=".".join(parts[1:]), value=value, data=data[key], _prefix=f"{_prefix}.{key}", ) elif isinstance(data, list): for item in data: inherit_value( path=".".join(parts[1:]), value=value, data=item[key], _prefix=f"{_prefix}.{key}", ) else: raise make_type_error(_prefix, key)
[docs] def apply_inheritance( data: dict[str, T.Any], ) -> None: """ Transform configuration data by applying all ``_defaults`` inheritance rules. This is the main entry point that processes an entire configuration structure, finding all _defaults sections and applying their inheritance rules to create the final resolved configuration. **What it does:** 1. Recursively processes nested _defaults sections (deeper ones override shallower ones) 2. Applies each JSON path pattern in the _defaults section in definition order 3. Removes all _defaults sections from the final output 4. Modifies the input data in-place **Path Execution Order Within Same _defaults:** Within a single _defaults section, paths are processed from top to bottom. If multiple paths affect the same node, the earlier path takes effect due to setdefault behavior. This enables powerful exception-then-default patterns. Example - setting defaults with specific exceptions:: { "_defaults": { "*.servers.blue.cpu": 4, # Exception: blue gets 4 CPU "*.servers.*.cpu": 2 # Default: all others get 2 CPU }, "env": { "servers": { "blue": {}, # Gets cpu=4 (from first rule) "green": {} # Gets cpu=2 (from second rule) } } } The exception must be defined before the wildcard pattern to take effect. **Child _defaults Overrides Parent _defaults:** Each nested object can have its own _defaults section. When both parent and child _defaults sections would affect the same node, the child wins due to processing order (children processed before parents). Example - nested inheritance hierarchy:: { "_defaults": { "*.servers.*.memory": 1024 # Parent default }, "env": { "servers": { "_defaults": { "*.memory": 2048 # Child override }, "web": {} # Gets memory=2048 (child wins) } } } This design allows fine-grained control where specific sections can override broader defaults while maintaining the inheritance hierarchy. **Basic Example**: >>> data = { ... "_defaults": { ... "*.memory": 2 ... }, ... "dev": {}, ... "prod": { ... "memory": 8 ... } ... } >>> apply_inheritance(data) >>> data { "dev": {"memory": 2}, # Inherited default "prod": {"memory": 8} # Kept existing value } :param data: Configuration dictionary with _defaults sections to process .. important:: The input param ``data`` will be modified in-place, all _defaults sections will be removed and their rules applied. If you want to keep the original data, do this before calling this function: .. code-block:: python import copy new_data = copy.deepcopy(data) apply_inheritance(new_data) """ # implement recursion pattern for key, value in data.items(): if key == DEFAULTS: continue if isinstance(value, dict): apply_inheritance(value) if isinstance(value, list): for item in value: if isinstance(item, dict): apply_inheritance(item) # try to set default values has_defaults = DEFAULTS in data if has_defaults is False: return # pop the defaults data, it is not needed in the final result defaults_data = data.pop(DEFAULTS) for path, value in defaults_data.items(): inherit_value(path=path, value=value, data=data)