From 9e5637bbd65a3f2f147d7bbb2c0524e2d06c0dae Mon Sep 17 00:00:00 2001 From: Ankit-cs Date: Thu, 15 Oct 2026 22:06:32 +0530 Subject: [PATCH] fix(core): recurse variable substitution into nested dicts and lists Related to #296 --- src/tirith/core/policy_parameterization.py | 40 +++++++++++-- tests/core/test_policy_parameterization.py | 66 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/tirith/core/policy_parameterization.py b/src/tirith/core/policy_parameterization.py index c34092af..3eeec4ef 100644 --- a/src/tirith/core/policy_parameterization.py +++ b/src/tirith/core/policy_parameterization.py @@ -15,14 +15,42 @@ def _replace_vars_in_dict(dictionary: dict, var_dict: dict, not_found_vars: List """ Replace the variables in the dictionary with the values from the var_dict - :param dictionary: The dictionary to replace the variables in - :param var_pattern: The pattern to match the variables - :param var_dict: The dictionary containing the variables + :param dictionary: The dictionary to replace the variables in + :param var_dict: The dictionary containing the variables + :param not_found_vars: The list to store the variables that are not found in """ for key, value in dictionary.items(): - if not isinstance(value, str): - continue - _replace_var_in_dict(dictionary, key, var_dict, not_found_vars) + if isinstance(value, str): + _replace_var_in_dict(dictionary, key, var_dict, not_found_vars) + elif isinstance(value, dict): + _replace_vars_in_dict(value, var_dict, not_found_vars) + elif isinstance(value, list): + _replace_vars_in_list(value, var_dict, not_found_vars) + + +def _replace_vars_in_list(target_list: list, var_dict: dict, not_found_vars: List[str]): + """ + Replace the variables in the list items with the values from the var_dict + + :param target_list: The list to replace the variables in + :param var_dict: The dictionary containing the variables + :param not_found_vars: The list to store the variables that are not found in + """ + for index, item in enumerate(target_list): + if isinstance(item, str): + match = _VAR_PATTERN.match(item) + if not match: + continue + var_name = match.group(1) + var_value = pydash.get(var_dict, var_name, default=_VariableNotFound) + if var_value is _VariableNotFound: + not_found_vars.append(var_name) + continue + target_list[index] = var_value + elif isinstance(item, dict): + _replace_vars_in_dict(item, var_dict, not_found_vars) + elif isinstance(item, list): + _replace_vars_in_list(item, var_dict, not_found_vars) def _replace_var_in_dict(dictionary: dict, key: str, var_dict: dict, not_found_vars: list): diff --git a/tests/core/test_policy_parameterization.py b/tests/core/test_policy_parameterization.py index 08a55682..26f03552 100644 --- a/tests/core/test_policy_parameterization.py +++ b/tests/core/test_policy_parameterization.py @@ -98,6 +98,72 @@ def test_same_policy_reused_with_different_vars(): assert second["evaluators"][0]["provider_args"]["key_path"] == "second.path" +def test_nested_dict_and_list_substitution(): + """Ensure substitution recurses into nested dicts and lists.""" + policy = { + "meta": {"version": ""}, + "evaluators": [ + { + "id": "check0", + "provider_args": { + "operation_type": "get_value", + "nested_options": {"path": "{{var.my_path}}"}, + }, + "condition": { + "type": "Equals", + "value": { + "env": "{{var.env}}", + "allowed_regions": ["us-east-1", "{{var.extra_region}}"], + "deep": [{"key": "{{var.deep_key}}"}], + }, + }, + } + ], + "eval_expression": "check0", + } + + var_dict = { + "my_path": "config.path", + "env": "production", + "extra_region": "eu-central-1", + "deep_key": "deep_secret", + } + + replaced, not_found = get_policy_with_vars_replaced(policy, var_dict) + + assert not_found == [] + assert replaced["evaluators"][0]["provider_args"]["nested_options"]["path"] == "config.path" + assert replaced["evaluators"][0]["condition"]["value"]["env"] == "production" + assert replaced["evaluators"][0]["condition"]["value"]["allowed_regions"] == ["us-east-1", "eu-central-1"] + assert replaced["evaluators"][0]["condition"]["value"]["deep"][0]["key"] == "deep_secret" + + +def test_nested_missing_variables_reported(): + """Ensure missing variables inside nested dicts and lists are added to not_found_vars.""" + policy = { + "meta": {"version": ""}, + "evaluators": [ + { + "id": "check0", + "provider_args": {}, + "condition": { + "type": "Equals", + "value": { + "nested_dict": {"missing_val": "{{var.missing_in_dict}}"}, + "nested_list": ["ok", "{{var.missing_in_list}}"], + }, + }, + } + ], + "eval_expression": "check0", + } + + replaced, not_found = get_policy_with_vars_replaced(policy, {}) + + assert "missing_in_dict" in not_found + assert "missing_in_list" in not_found + + # TODO: Create testcases for: # - test inline vars precendece over var files # - test undefined vars