variables.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """
  2. Core variables support for interactive collection and detection.
  3. Provides a BaseVariables class that can detect which variable sets are used
  4. in a Jinja2 template and interactively collect values from the user.
  5. """
  6. from typing import Dict, List, Tuple, Set, Any
  7. import jinja2
  8. from jinja2 import meta
  9. import typer
  10. from .prompt import PromptHandler
  11. class BaseVariables:
  12. """Base implementation for variable sets and interactive prompting.
  13. Subclasses should set `variable_sets` to one of two shapes:
  14. 1) Legacy shape (mapping of set-name -> { var_name: { ... } })
  15. { "general": { "foo": { ... }, ... } }
  16. 2) New shape (mapping of set-name -> { "prompt": str, "variables": { var_name: { ... } } })
  17. { "general": { "prompt": "...", "variables": { "foo": { ... } } } }
  18. """
  19. variable_sets: Dict[str, Dict[str, Any]] = {}
  20. def __init__(self) -> None:
  21. # Flattened list of all declared variable names -> (set_name, meta)
  22. self._declared: Dict[str, Tuple[str, Dict[str, Any]]] = {}
  23. # Support both legacy and new shapes. If the set value contains a
  24. # 'variables' key, use that mapping; otherwise assume the mapping is
  25. # directly the vars map (legacy).
  26. if not hasattr(self, "variable_sets"):
  27. self.variable_sets = {}
  28. # Ensure we can iterate over variable_sets
  29. if not isinstance(self.variable_sets, dict):
  30. self.variable_sets = {}
  31. for set_name, set_def in self.variable_sets.items():
  32. vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
  33. if not isinstance(vars_map, dict):
  34. continue
  35. for var_name, meta_info in vars_map.items():
  36. self._declared[var_name] = (set_name, meta_info)
  37. def find_used_variables(self, template_content: str) -> Set[str]:
  38. """Parse the Jinja2 template and return the set of variable names used."""
  39. env = jinja2.Environment()
  40. try:
  41. ast = env.parse(template_content)
  42. used = meta.find_undeclared_variables(ast)
  43. return set(used)
  44. except Exception:
  45. # If parsing fails, fallback to an empty set (safe behavior)
  46. return set()
  47. def find_used_subscript_keys(self, template_content: str) -> Dict[str, Set[str]]:
  48. """Return mapping of variable name -> set of string keys accessed via subscripting
  49. Example: for template using service_port['http'] and service_port['https']
  50. this returns { 'service_port': {'http', 'https'} }.
  51. """
  52. try:
  53. env = jinja2.Environment()
  54. ast = env.parse(template_content)
  55. # Walk AST and collect Subscript nodes
  56. from jinja2 import nodes
  57. subs: Dict[str, Set[str]] = {}
  58. for node in ast.find_all(nodes.Getitem):
  59. # Getitem node structure: node.node (value), node.arg (index)
  60. try:
  61. if isinstance(node.node, nodes.Name):
  62. var_name = node.node.name
  63. # index can be Const (string) or Name/other; handle Const
  64. idx = node.arg
  65. if isinstance(idx, nodes.Const) and isinstance(idx.value, str):
  66. subs.setdefault(var_name, set()).add(idx.value)
  67. except Exception:
  68. continue
  69. return subs
  70. except Exception:
  71. return {}
  72. def extract_template_defaults(self, template_content: str) -> Dict[str, Any]:
  73. """Extract default values from Jinja2 expressions like {{ var | default(value) }}."""
  74. import re
  75. def _parse_literal(s: str):
  76. s = s.strip()
  77. if s.startswith("'") and s.endswith("'"):
  78. return s[1:-1]
  79. if s.startswith('"') and s.endswith('"'):
  80. return s[1:-1]
  81. if s.isdigit():
  82. return int(s)
  83. return s
  84. defaults: Dict[str, Any] = {}
  85. # Match {{ var['key'] | default(value) }} and {{ var | default(value) }}
  86. pattern_subscript = r'\{\{\s*(\w+)\s*\[\s*["\']([^"\']+)["\']\s*\]\s*\|\s*default\(([^)]+)\)\s*\}\}'
  87. for var, key, default_str in re.findall(pattern_subscript, template_content):
  88. if var not in defaults or not isinstance(defaults[var], dict):
  89. defaults[var] = {}
  90. defaults[var][key] = _parse_literal(default_str)
  91. pattern_scalar = r'\{\{\s*(\w+)\s*\|\s*default\(([^)]+)\)\s*\}\}'
  92. for var, default_str in re.findall(pattern_scalar, template_content):
  93. # Only set scalar default if not already set as a dict
  94. if var not in defaults:
  95. defaults[var] = _parse_literal(default_str)
  96. # Handle simple {% set name = other | default('val') %} patterns
  97. set_pattern = r"\{%\s*set\s+(\w+)\s*=\s*([^%]+?)\s*%}"
  98. for set_var, expr in re.findall(set_pattern, template_content):
  99. m = re.match(r"(\w+)\s*\|\s*default\(([^)]+)\)", expr.strip())
  100. if m:
  101. src_var, src_default = m.groups()
  102. if src_var in defaults:
  103. defaults[set_var] = defaults[src_var]
  104. else:
  105. defaults[set_var] = _parse_literal(src_default)
  106. # Resolve transitive references: if a default is an identifier that
  107. # points to another default, follow it; if it points to a declared
  108. # variable with a metadata default, use that.
  109. def _resolve_ref(value, seen: Set[str]):
  110. if not isinstance(value, str):
  111. return value
  112. if value in seen:
  113. return value
  114. seen.add(value)
  115. if value in defaults:
  116. return _resolve_ref(defaults[value], seen)
  117. if value in self._declared:
  118. declared_def = self._declared[value][1].get("default")
  119. if declared_def is not None:
  120. return declared_def
  121. return value
  122. for k in list(defaults.keys()):
  123. defaults[k] = _resolve_ref(defaults[k], set([k]))
  124. return defaults
  125. def extract_variable_meta_overrides(self, template_content: str) -> Dict[str, Dict[str, Any]]:
  126. """Extract variable metadata overrides from a Jinja2 block.
  127. Supports a block like:
  128. {% variables %}
  129. container_hostname:
  130. description: "..."
  131. {% endvariables %}
  132. The contents are parsed as YAML and returned as a dict mapping
  133. variable name -> metadata overrides.
  134. """
  135. import re
  136. try:
  137. m = re.search(r"\{%\s*variables\s*%\}(.+?)\{%\s*endvariables\s*%\}", template_content, flags=re.S)
  138. if not m:
  139. return {}
  140. yaml_block = m.group(1).strip()
  141. try:
  142. import yaml
  143. except Exception:
  144. return {}
  145. try:
  146. data = yaml.safe_load(yaml_block) or {}
  147. if isinstance(data, dict):
  148. # Ensure values are dicts
  149. cleaned: Dict[str, Dict[str, Any]] = {}
  150. for k, v in data.items():
  151. if v is None:
  152. cleaned[k] = {}
  153. elif isinstance(v, dict):
  154. cleaned[k] = v
  155. else:
  156. # If a scalar was provided, interpret as description
  157. cleaned[k] = {"description": v}
  158. return cleaned
  159. except Exception:
  160. return {}
  161. except Exception:
  162. return {}
  163. return {}
  164. def determine_variable_sets(self, template_content: str) -> Tuple[List[str], Set[str]]:
  165. """
  166. Also returns the raw set of used variable names.
  167. """
  168. used = self.find_used_variables(template_content)
  169. matched_sets: List[str] = []
  170. variable_sets = getattr(self, "variable_sets", {})
  171. if not isinstance(variable_sets, dict):
  172. return [], used
  173. for set_name, set_def in variable_sets.items():
  174. vars_map = set_def.get("variables") if isinstance(set_def, dict) and "variables" in set_def else set_def
  175. if not isinstance(vars_map, dict):
  176. continue
  177. if any(var in used for var in vars_map.keys()):
  178. matched_sets.append(set_name)
  179. return matched_sets, used
  180. def collect_values(self, used_vars: Set[str], template_defaults: Dict[str, Any] = None, used_subscripts: Dict[str, Set[str]] = None) -> Dict[str, Any]:
  181. """Interactively prompt for values for the variables that appear in the template.
  182. For variables that were declared in `variable_sets` we use their metadata.
  183. For unknown variables, we fall back to a generic prompt.
  184. """
  185. prompt_handler = PromptHandler(self._declared, getattr(self, "variable_sets", {}))
  186. return prompt_handler.collect_values(used_vars, template_defaults, used_subscripts)