Source code for nicetoolbox.configs.placeholders

import re
from typing import Any, Optional, Union

from pydantic import BaseModel

from .utils import keys_collision_dict, model_to_dict

# Pattern matches <key> where key can contain alphanumeric, underscore and hyphen
# This is guaranteed to be valid TOML field name
PLACEHOLDER_PATTERN = re.compile(r"<([a-zA-Z0-9_-]+)>")

# Only this types are allowed to be placeholder values
# Other types will be ignored or cause undefined behaviour
PLACEHOLDERS_TYPE = Union[str, int, float, bool]


[docs]def get_placeholders_str(input: str) -> set[str]: """Extract all placeholder names from a string.""" return set(re.findall(PLACEHOLDER_PATTERN, input))
[docs]def get_placeholders(input: Any) -> set[str]: """Extract all placeholder names from any data structure.""" if isinstance(input, str): return get_placeholders_str(input) if isinstance(input, dict): placeholders = set() for value in input.values(): placeholders.update(get_placeholders(value)) return placeholders if isinstance(input, list): placeholders = set() for item in input: placeholders.update(get_placeholders(item)) return placeholders if isinstance(input, BaseModel): # Convert to dict and recursively extract return get_placeholders(model_to_dict(input)) # For other types (int, float, bool, None), no placeholders return set()
[docs]def resolve_placeholders_str( input: str, placeholders: dict[str, PLACEHOLDERS_TYPE], unresolved: Optional[set[str]] = None, unreachable: Optional[set[str]] = None, ) -> str: r""" Find and resolve placeholders in a string with corresponding values from dictionary. Placeholders should be formatted as \<var\> strings. If placeholders key isn't inside dict, it will be ignored and added to optional unresolved placeholders set. Args: input (str): The string containing placeholders to be replaced. placeholders (dict[str, PLACEHOLDERS_TYPE]): A dictionary containing the placeholder values. unresolved (set, optional): A set to collect unresolved placeholder keys. Placeholder is unresolved if it's not present in dict or its value contains unresolved placeholders (including unreachable placeholders). unreachable (set, optional): Set of placeholder names that are allowed to remain unresolved (e.g., runtime placeholders resolved later). Returns: str: The string with placeholders replaced. """ if unresolved is None: unresolved = set() if unreachable is None: unreachable = set() # function to replace matched placeholders with dict values def replace_match(match: re.Match[str]) -> str: orig_str = match.group(0) # full input string "<var>" key = match.group(1) # actual name "var" # if placeholder's unknown - mark it unresolved and return as is if key not in placeholders: unresolved.add(key) return orig_str # if placeholder's known - check if replacement contains unknown placeholders # we mark all unknown placeholders replacement as unresolved replacement = str(placeholders[key]) # force placeholder to string unknown_placeholders = get_placeholders_str(replacement) - unreachable if unknown_placeholders: unresolved.add(key) return replacement # replace all known placeholders keys with provided values return re.sub(PLACEHOLDER_PATTERN, replace_match, input)
[docs]def resolve_placeholders_str_strict( input: str, placeholders: dict[str, PLACEHOLDERS_TYPE], unreachable: Optional[set[str]] = None, ) -> str: """ Same as resolve_placeholders_str, but raise if finds unexpected placeholders. """ if unreachable is None: unreachable = set() unresolved = set() result = resolve_placeholders_str(input, placeholders, unresolved, unreachable) unexpected = unresolved - unreachable if unexpected: raise ValueError(f"Could not resolve placeholders: {unexpected}. " f"Check for typos or circular dependencies.") return result
[docs]def resolve_placeholders_dict_mut( input: dict[str, PLACEHOLDERS_TYPE], placeholders: dict[str, PLACEHOLDERS_TYPE], unreachable: Optional[set[str]] = None, max_iterations: int = 5, ) -> dict[str, Any]: r""" Resolve placeholders in a dict using both provided context and self-references. Iteratively resolves \<var\> placeholders in string values using: 1. Provided placeholders dict 2. Other string values within the same dict (self-reference) Input placeholders dictionary will be mutated with new local placeholders. Resolution continues until no changes occur or max_iterations is reached. Non-string values (numbers, lists, nested dicts) are returned unchanged. Function expects to resolve all known placeholders or raise an error. Args: input(dict[str, PLACEHOLDERS_TYPE]): Dict containing string values to resolve. placeholders(dict[str, Any]): Context dict for resolution. Will be mutated to include resolved string values from input. unreachable(Optional[set[str]]): Placeholder names that are allowed to remain unresolved (e.g., runtime placeholders resolved later). max_iterations(int): Maximum resolution passes to handle chained dependencies. Returns: Dict with all resolvable placeholders replaced. Raises: ValueError: If unresolved placeholders remain that are not in unreachable (indicates typos or circular dependencies). KeyError: If placeholders and input dicts have names collision (including non-string fields names) """ # check fields name collision collision = keys_collision_dict(placeholders, input) if collision: raise KeyError(f"Fields collision between local and " f"placeholders field names: {collision}") # default values and copies if unreachable is None: unreachable = set() result = dict(input) for _ in range(max_iterations): # update placeholders context with local dict fields local_ctx = {k: v for k, v in result.items() if isinstance(v, PLACEHOLDERS_TYPE)} placeholders.update(local_ctx) # try to resolve placeholders with combined context unresolved: set[str] = set() new_result = { k: resolve_placeholders_str(v, placeholders, unresolved, unreachable) if isinstance(v, str) else v for k, v in result.items() } # no changes? either fully resolved or stuck if new_result == result: unexpected = unresolved - unreachable if unexpected: raise ValueError( f"Could not resolve placeholders: {unexpected}. " f"Check for typos or circular dependencies." ) return new_result # update results and start next iteration result = new_result # Did all iterations - still didn't converged # Looks like we have circular dependency raise ValueError( f"Couldn't resolve placeholders after {max_iterations} iterations. " f"Unexpected placeholders: {unresolved - unreachable}" )
[docs]def resolve_placeholders( input: Any, placeholders: dict[str, PLACEHOLDERS_TYPE], unreachable: Optional[set[str]] = None, ) -> Any: r""" Recursively find and resolve placeholders in input data with corresponding values. Placeholders should be formatted as \<var\> strings inside data structure fields. If placeholders key isn't inside dict or can't be resolved from local context, it will rise resolution error. Args: input: The data structure to be processed. Only strings, lists, dicts and pydantics.BaseModel are processed. Other input is returned as is. placeholders (dict[str, PLACEHOLDERS_TYPE]): A dictionary containing the placeholder values. unreachable (set, optional): Set of placeholder names that are allowed to remain unresolved (e.g., runtime placeholders resolved later). Returns: The copy of processed data structure with placeholders replaced. Raises: ValueError: If dict-level placeholders cannot be resolved (circular dependencies or missing placeholders that are not in unreachable set). KeyError: If there's a field name collision between input dict and placeholders dict when processing inputs. """ if isinstance(input, dict): new_placeholders = dict(placeholders) # resolve placeholders on a local level # copy for avoiding original placeholders mutation result = resolve_placeholders_dict_mut(input, new_placeholders, unreachable) # run recursively for all non-strings (strings should be already resolved) # we send a copy of placeholders to avoid contamination return { k: resolve_placeholders(v, new_placeholders, unreachable) if not isinstance(v, str) else v for k, v in result.items() } if isinstance(input, list): return [resolve_placeholders(item, placeholders, unreachable) for item in input] # TODO: support for sets and tuples? # for pydantic models: convert them to dict, recursively resolve it and convert back # we do it with validation in case of custom post-validation hooks if isinstance(input, BaseModel): input_dict = model_to_dict(input) processed_dict = resolve_placeholders(input_dict, placeholders, unreachable) return type(input).model_validate(processed_dict) # finally strings are just properly resolved if isinstance(input, str): return resolve_placeholders_str_strict(input, placeholders, unreachable) # if we got any other type during recursion (e.g. float or int), we return it as is return input