variable_collection.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034
  1. from __future__ import annotations
  2. import logging
  3. from collections import defaultdict
  4. from typing import Any
  5. from ..exceptions import VariableError, VariableValidationError
  6. from .variable import Variable
  7. from .variable_section import VariableSection
  8. logger = logging.getLogger(__name__)
  9. class VariableCollection:
  10. """Manages variables grouped by sections and builds Jinja context."""
  11. def __init__(self, spec: dict[str, Any]) -> None:
  12. """Initialize VariableCollection from a specification dictionary.
  13. Args:
  14. spec: Dictionary containing the complete variable specification structure
  15. Expected format (as used in compose.py):
  16. {
  17. "section_key": {
  18. "title": "Section Title",
  19. "prompt": "Optional prompt text",
  20. "toggle": "optional_toggle_var_name",
  21. "description": "Optional description",
  22. "vars": {
  23. "var_name": {
  24. "description": "Variable description",
  25. "type": "str",
  26. "default": "default_value",
  27. ...
  28. }
  29. }
  30. }
  31. }
  32. """
  33. if not isinstance(spec, dict):
  34. raise ValueError("Spec must be a dictionary")
  35. self._sections: dict[str, VariableSection] = {}
  36. # NOTE: The _variable_map provides a flat, O(1) lookup for any variable by its name,
  37. # avoiding the need to iterate through sections. It stores references to the same
  38. # Variable objects contained in the _set structure.
  39. self._variable_map: dict[str, Variable] = {}
  40. self._initialize_sections(spec)
  41. # Validate dependencies after all sections are loaded
  42. self._validate_dependencies()
  43. def _initialize_sections(self, spec: dict[str, Any]) -> None:
  44. """Initialize sections from the spec."""
  45. for section_key, section_data in spec.items():
  46. if not isinstance(section_data, dict):
  47. continue
  48. section = self._create_section(section_key, section_data)
  49. # Guard against None from empty YAML sections (vars: with no content)
  50. vars_data = section_data.get("vars") or {}
  51. self._initialize_variables(section, vars_data)
  52. self._sections[section_key] = section
  53. # Validate all variable names are unique across sections
  54. self._validate_unique_variable_names()
  55. def _create_section(self, key: str, data: dict[str, Any]) -> VariableSection:
  56. """Create a VariableSection from data."""
  57. # Build section init data with only explicitly provided fields
  58. # This prevents None values from overriding module spec values during merge
  59. section_init_data = {
  60. "key": key,
  61. "title": data.get("title", key.replace("_", " ").title()),
  62. }
  63. # Only add optional fields if explicitly provided in the source data
  64. if "description" in data:
  65. section_init_data["description"] = data["description"]
  66. if "toggle" in data:
  67. section_init_data["toggle"] = data["toggle"]
  68. if "required" in data:
  69. section_init_data["required"] = data["required"]
  70. elif key == "general":
  71. section_init_data["required"] = True
  72. if "needs" in data:
  73. section_init_data["needs"] = data["needs"]
  74. return VariableSection(section_init_data)
  75. def _initialize_variables(
  76. self, section: VariableSection, vars_data: dict[str, Any]
  77. ) -> None:
  78. """Initialize variables for a section."""
  79. # Guard against None from empty YAML sections
  80. if vars_data is None:
  81. vars_data = {}
  82. for var_name, var_data in vars_data.items():
  83. var_init_data = {"name": var_name, "parent_section": section, **var_data}
  84. variable = Variable(var_init_data)
  85. section.variables[var_name] = variable
  86. # NOTE: Populate the direct lookup map for efficient access.
  87. self._variable_map[var_name] = variable
  88. # Validate toggle variable after all variables are added
  89. self._validate_section_toggle(section)
  90. # TODO: Add more section-level validation:
  91. # - Validate that required sections have at least one non-toggle variable
  92. # - Validate that enum variables have non-empty options lists
  93. # - Validate that variable names follow naming conventions (e.g., lowercase_with_underscores)
  94. # - Validate that default values are compatible with their type definitions
  95. def _validate_unique_variable_names(self) -> None:
  96. """Validate that all variable names are unique across all sections."""
  97. var_to_sections: dict[str, list[str]] = defaultdict(list)
  98. # Build mapping of variable names to sections
  99. for section_key, section in self._sections.items():
  100. for var_name in section.variables:
  101. var_to_sections[var_name].append(section_key)
  102. # Find duplicates and format error
  103. duplicates = {
  104. var: sections
  105. for var, sections in var_to_sections.items()
  106. if len(sections) > 1
  107. }
  108. if duplicates:
  109. errors = [
  110. "Variable names must be unique across all sections, but found duplicates:"
  111. ]
  112. errors.extend(
  113. f" - '{var}' appears in sections: {', '.join(secs)}"
  114. for var, secs in sorted(duplicates.items())
  115. )
  116. errors.append(
  117. "\nPlease rename variables to be unique or consolidate them into a single section."
  118. )
  119. error_msg = "\n".join(errors)
  120. logger.error(error_msg)
  121. raise VariableValidationError("__collection__", error_msg)
  122. def _validate_section_toggle(self, section: VariableSection) -> None:
  123. """Validate that toggle variable is of type bool if it exists.
  124. If the toggle variable doesn't exist (e.g., filtered out), removes the toggle.
  125. Args:
  126. section: The section to validate
  127. Raises:
  128. ValueError: If toggle variable exists but is not boolean type
  129. """
  130. if not section.toggle:
  131. return
  132. toggle_var = section.variables.get(section.toggle)
  133. if not toggle_var:
  134. # Toggle variable doesn't exist (e.g., was filtered out) - remove toggle metadata
  135. section.toggle = None
  136. return
  137. if toggle_var.type != "bool":
  138. raise VariableValidationError(
  139. section.toggle,
  140. f"Section '{section.key}' toggle variable must be type 'bool', but is type '{toggle_var.type}'",
  141. )
  142. @staticmethod
  143. def _parse_need(need_str: str) -> tuple[str, Any | None]:
  144. """Parse a need string into variable name and expected value(s).
  145. Supports three formats:
  146. 1. New format with multiple values: "variable_name=value1,value2" - checks if variable equals any value
  147. 2. New format with single value: "variable_name=value" - checks if variable equals value
  148. 3. Old format (backwards compatibility): "section_name" - checks if section is enabled
  149. Args:
  150. need_str: Need specification string
  151. Returns:
  152. Tuple of (variable_or_section_name, expected_value)
  153. For old format, expected_value is None (means check section enabled)
  154. For new format, expected_value is the string value(s) after '=' (string or list)
  155. Examples:
  156. "traefik_enabled=true" -> ("traefik_enabled", "true")
  157. "storage_mode=nfs" -> ("storage_mode", "nfs")
  158. "network_mode=bridge,macvlan" -> ("network_mode", ["bridge", "macvlan"])
  159. "traefik" -> ("traefik", None) # Old format: section name
  160. """
  161. if "=" in need_str:
  162. # New format: variable=value or variable=value1,value2
  163. parts = need_str.split("=", 1)
  164. var_name = parts[0].strip()
  165. value_part = parts[1].strip()
  166. # Check if multiple values are provided (comma-separated)
  167. if "," in value_part:
  168. values = [v.strip() for v in value_part.split(",")]
  169. return (var_name, values)
  170. else:
  171. return (var_name, value_part)
  172. else:
  173. # Old format: section name (backwards compatibility)
  174. return (need_str.strip(), None)
  175. def _is_need_satisfied(self, need_str: str) -> bool:
  176. """Check if a single need condition is satisfied.
  177. Args:
  178. need_str: Need specification ("variable=value", "variable=value1,value2" or "section_name")
  179. Returns:
  180. True if need is satisfied, False otherwise
  181. """
  182. var_or_section, expected_value = self._parse_need(need_str)
  183. if expected_value is None:
  184. # Old format: check if section is enabled (backwards compatibility)
  185. section = self._sections.get(var_or_section)
  186. if not section:
  187. logger.warning(f"Need references missing section '{var_or_section}'")
  188. return False
  189. return section.is_enabled()
  190. else:
  191. # New format: check if variable has expected value(s)
  192. variable = self._variable_map.get(var_or_section)
  193. if not variable:
  194. logger.warning(f"Need references missing variable '{var_or_section}'")
  195. return False
  196. # Convert actual value for comparison
  197. try:
  198. actual_value = variable.convert(variable.value)
  199. # Handle multiple expected values (comma-separated in needs)
  200. if isinstance(expected_value, list):
  201. # Check if actual value matches any of the expected values
  202. for expected in expected_value:
  203. expected_converted = variable.convert(expected)
  204. # Handle boolean comparisons specially
  205. if variable.type == "bool":
  206. if bool(actual_value) == bool(expected_converted):
  207. return True
  208. # String comparison for other types
  209. elif actual_value is not None and str(actual_value) == str(
  210. expected_converted
  211. ):
  212. return True
  213. return False # None of the expected values matched
  214. else:
  215. # Single expected value (original behavior)
  216. expected_converted = variable.convert(expected_value)
  217. # Handle boolean comparisons specially
  218. if variable.type == "bool":
  219. return bool(actual_value) == bool(expected_converted)
  220. # String comparison for other types
  221. return (
  222. str(actual_value) == str(expected_converted)
  223. if actual_value is not None
  224. else False
  225. )
  226. except Exception as e:
  227. logger.debug(f"Failed to compare need '{need_str}': {e}")
  228. return False
  229. def _validate_dependencies(self) -> None:
  230. """Validate section dependencies for cycles and missing references.
  231. Raises:
  232. ValueError: If circular dependencies or missing section references are found
  233. """
  234. # Check for missing dependencies in sections
  235. for section_key, section in self._sections.items():
  236. for dep in section.needs:
  237. var_or_section, expected_value = self._parse_need(dep)
  238. if expected_value is None:
  239. # Old format: validate section exists
  240. if var_or_section not in self._sections:
  241. raise VariableError(
  242. f"Section '{section_key}' depends on '{var_or_section}', but '{var_or_section}' does not exist"
  243. )
  244. # New format: validate variable exists
  245. # NOTE: We only warn here, not raise an error, because the variable might be
  246. # added later during merge with module spec. The actual runtime check in
  247. # _is_need_satisfied() will handle missing variables gracefully.
  248. elif (
  249. expected_value is not None
  250. and var_or_section not in self._variable_map
  251. ):
  252. logger.debug(
  253. f"Section '{section_key}' has need '{dep}', but variable '{var_or_section}' "
  254. f"not found (might be added during merge)"
  255. )
  256. # Check for missing dependencies in variables
  257. for var_name, variable in self._variable_map.items():
  258. for dep in variable.needs:
  259. dep_var, expected_value = self._parse_need(dep)
  260. # Only validate new format
  261. if expected_value is not None and dep_var not in self._variable_map:
  262. # NOTE: We only warn here, not raise an error, because the variable might be
  263. # added later during merge with module spec. The actual runtime check in
  264. # _is_need_satisfied() will handle missing variables gracefully.
  265. logger.debug(
  266. f"Variable '{var_name}' has need '{dep}', but variable '{dep_var}' "
  267. f"not found (might be added during merge)"
  268. )
  269. # Check for circular dependencies using depth-first search
  270. # Note: Only checks section-level dependencies in old format (section names)
  271. # Variable-level dependencies (variable=value) don't create cycles in the same way
  272. visited = set()
  273. rec_stack = set()
  274. def has_cycle(section_key: str) -> bool:
  275. visited.add(section_key)
  276. rec_stack.add(section_key)
  277. section = self._sections[section_key]
  278. for dep in section.needs:
  279. # Only check circular deps for old format (section references)
  280. dep_name, expected_value = self._parse_need(dep)
  281. if expected_value is None and dep_name in self._sections:
  282. # Old format section dependency - check for cycles
  283. if dep_name not in visited:
  284. if has_cycle(dep_name):
  285. return True
  286. elif dep_name in rec_stack:
  287. raise VariableError(
  288. f"Circular dependency detected: '{section_key}' depends on '{dep_name}', "
  289. f"which creates a cycle"
  290. )
  291. rec_stack.remove(section_key)
  292. return False
  293. for section_key in self._sections:
  294. if section_key not in visited:
  295. has_cycle(section_key)
  296. def is_section_satisfied(self, section_key: str) -> bool:
  297. """Check if all dependencies for a section are satisfied.
  298. Supports both formats:
  299. - Old format: "section_name" - checks if section is enabled (backwards compatible)
  300. - New format: "variable=value" - checks if variable has specific value
  301. Args:
  302. section_key: The key of the section to check
  303. Returns:
  304. True if all dependencies are satisfied, False otherwise
  305. """
  306. section = self._sections.get(section_key)
  307. if not section:
  308. return False
  309. # No dependencies = always satisfied
  310. if not section.needs:
  311. return True
  312. # Check each dependency using the unified need satisfaction logic
  313. for need in section.needs:
  314. if not self._is_need_satisfied(need):
  315. logger.debug(f"Section '{section_key}' need '{need}' is not satisfied")
  316. return False
  317. return True
  318. def is_variable_satisfied(self, var_name: str) -> bool:
  319. """Check if all dependencies for a variable are satisfied.
  320. A variable is satisfied if all its needs are met.
  321. Needs are specified as "variable_name=value".
  322. Args:
  323. var_name: The name of the variable to check
  324. Returns:
  325. True if all dependencies are satisfied, False otherwise
  326. """
  327. variable = self._variable_map.get(var_name)
  328. if not variable:
  329. return False
  330. # No dependencies = always satisfied
  331. if not variable.needs:
  332. return True
  333. # Check each dependency
  334. for need in variable.needs:
  335. if not self._is_need_satisfied(need):
  336. logger.debug(f"Variable '{var_name}' need '{need}' is not satisfied")
  337. return False
  338. return True
  339. def reset_disabled_bool_variables(self) -> list[str]:
  340. """Reset bool variables with unsatisfied dependencies to False.
  341. This ensures that disabled bool variables don't accidentally remain True
  342. and cause confusion in templates or configuration.
  343. Note: CLI-provided variables are NOT reset here - they are validated
  344. later in validate_all() to provide better error messages.
  345. Returns:
  346. List of variable names that were reset
  347. """
  348. reset_vars = []
  349. # Pre-compute satisfaction states to avoid repeated lookups
  350. section_states = {
  351. key: (self.is_section_satisfied(key), section.is_enabled())
  352. for key, section in self._sections.items()
  353. }
  354. for section_key, section in self._sections.items():
  355. section_satisfied, is_enabled = section_states[section_key]
  356. for var_name, variable in section.variables.items():
  357. # Only process bool variables
  358. if variable.type != "bool":
  359. continue
  360. # Check if variable's own dependencies are satisfied
  361. var_satisfied = self.is_variable_satisfied(var_name)
  362. # If section is disabled OR variable dependencies aren't met, reset to False
  363. # Only reset if current value is not already False and not CLI-provided
  364. if (
  365. (not section_satisfied or not is_enabled or not var_satisfied)
  366. and variable.value is not False
  367. and variable.origin != "cli"
  368. ):
  369. # Store original value if not already stored (for display purposes)
  370. if not hasattr(variable, "_original_disabled"):
  371. variable._original_disabled = variable.value
  372. variable.value = False
  373. reset_vars.append(var_name)
  374. logger.debug(
  375. f"Reset disabled bool variable '{var_name}' to False "
  376. f"(section satisfied: {section_satisfied}, enabled: {is_enabled}, "
  377. f"var satisfied: {var_satisfied})"
  378. )
  379. return reset_vars
  380. def sort_sections(self) -> None:
  381. """Sort sections with the following priority:
  382. 1. Dependencies come before dependents (topological sort)
  383. 2. Required sections first (in their original order)
  384. 3. Enabled sections with satisfied dependencies next (in their original order)
  385. 4. Disabled sections or sections with unsatisfied dependencies last (in their original order)
  386. This maintains the original ordering within each group while organizing
  387. sections logically for display and user interaction, and ensures that
  388. sections are prompted in the correct dependency order.
  389. """
  390. # First, perform topological sort to respect dependencies
  391. sorted_keys = self._topological_sort()
  392. # Then apply priority sorting within dependency groups
  393. section_items = [(key, self._sections[key]) for key in sorted_keys]
  394. # Define sort key: (priority, original_index)
  395. # Priority: 0 = required, 1 = enabled with satisfied dependencies, 2 = disabled or unsatisfied dependencies
  396. def get_sort_key(item_with_index):
  397. index, (key, section) = item_with_index
  398. if section.required:
  399. priority = 0
  400. elif section.is_enabled() and self.is_section_satisfied(key):
  401. priority = 1
  402. else:
  403. priority = 2
  404. return (priority, index)
  405. # Sort with original index to maintain order within each priority group
  406. # Note: This preserves the topological order from earlier
  407. sorted_items = sorted(enumerate(section_items), key=get_sort_key)
  408. # Rebuild _sections dict in new order
  409. self._sections = {key: section for _, (key, section) in sorted_items}
  410. # NOTE: Sort variables within each section by their dependencies.
  411. # This is critical for correct behavior in both display and prompts:
  412. # 1. DISPLAY: Variables are shown in logical order (dependencies before dependents)
  413. # 2. PROMPTS: Users are asked for dependency values BEFORE dependent values
  414. # Example: network_mode (bridge/host/macvlan) is prompted before
  415. # network_macvlan_ipv4_address (which needs network_mode=macvlan)
  416. # 3. VALIDATION: Ensures config/CLI overrides can be checked in correct order
  417. # Without this sorting, users would be prompted for irrelevant variables or see
  418. # confusing variable order in the UI.
  419. for section in self._sections.values():
  420. section.sort_variables(self._is_need_satisfied)
  421. def _topological_sort(self) -> list[str]:
  422. """Perform topological sort on sections based on dependencies using Kahn's algorithm."""
  423. in_degree = {key: len(section.needs) for key, section in self._sections.items()}
  424. queue = [key for key, degree in in_degree.items() if degree == 0]
  425. queue.sort(
  426. key=lambda k: list(self._sections.keys()).index(k)
  427. ) # Preserve original order
  428. result = []
  429. while queue:
  430. current = queue.pop(0)
  431. result.append(current)
  432. # Update in-degree for dependent sections
  433. for key, section in self._sections.items():
  434. if current in section.needs:
  435. in_degree[key] -= 1
  436. if in_degree[key] == 0:
  437. queue.append(key)
  438. # Fallback to original order if cycle detected
  439. if len(result) != len(self._sections):
  440. logger.warning("Topological sort incomplete - using original order")
  441. return list(self._sections.keys())
  442. return result
  443. def iter_active_sections(
  444. self,
  445. include_disabled: bool = False,
  446. include_unsatisfied: bool = False,
  447. ):
  448. """Iterate over sections respecting dependencies and toggles.
  449. This is the centralized iterator for processing sections with proper
  450. filtering. It eliminates duplicate iteration logic across the codebase.
  451. Args:
  452. include_disabled: If True, include sections that are disabled via toggle
  453. include_unsatisfied: If True, include sections with unsatisfied dependencies
  454. Yields:
  455. Tuple of (section_key, section) for each active section
  456. Examples:
  457. # Only enabled sections with satisfied dependencies (default)
  458. for key, section in variables.iter_active_sections():
  459. process(section)
  460. # Include disabled sections but skip unsatisfied dependencies
  461. for key, section in variables.iter_active_sections(include_disabled=True):
  462. process(section)
  463. """
  464. for section_key, section in self._sections.items():
  465. # Check dependencies first
  466. if not include_unsatisfied and not self.is_section_satisfied(section_key):
  467. logger.debug(
  468. f"Skipping section '{section_key}' - dependencies not satisfied"
  469. )
  470. continue
  471. # Check enabled status
  472. if not include_disabled and not section.is_enabled():
  473. logger.debug(f"Skipping section '{section_key}' - section is disabled")
  474. continue
  475. yield section_key, section
  476. def get_sections(self) -> dict[str, VariableSection]:
  477. """Get all sections in the collection."""
  478. return self._sections.copy()
  479. def get_section(self, key: str) -> VariableSection | None:
  480. """Get a specific section by its key."""
  481. return self._sections.get(key)
  482. def has_sections(self) -> bool:
  483. """Check if the collection has any sections."""
  484. return bool(self._sections)
  485. def get_all_values(self) -> dict[str, Any]:
  486. """Get all variable values as a dictionary."""
  487. # NOTE: Uses _variable_map for O(1) access
  488. return {
  489. name: var.convert(var.value) for name, var in self._variable_map.items()
  490. }
  491. def get_satisfied_values(self) -> dict[str, Any]:
  492. """Get variable values only from sections with satisfied dependencies.
  493. This respects both toggle states and section dependencies, ensuring that:
  494. - Variables from disabled sections (toggle=false) are excluded EXCEPT required variables
  495. - Variables from sections with unsatisfied dependencies are excluded
  496. - Required variables are always included if their section dependencies are satisfied
  497. Returns:
  498. Dictionary of variable names to values for satisfied sections only
  499. """
  500. satisfied_values = {}
  501. for section_key, section in self._sections.items():
  502. # Skip sections with unsatisfied dependencies (even required variables need satisfied deps)
  503. if not self.is_section_satisfied(section_key):
  504. logger.debug(
  505. f"Excluding variables from section '{section_key}' - dependencies not satisfied"
  506. )
  507. continue
  508. # Check if section is enabled
  509. is_enabled = section.is_enabled()
  510. if is_enabled:
  511. # Include all variables from enabled section
  512. for var_name, variable in section.variables.items():
  513. satisfied_values[var_name] = variable.convert(variable.value)
  514. else:
  515. # Section is disabled - only include required variables
  516. logger.debug(
  517. f"Section '{section_key}' is disabled - including only required variables"
  518. )
  519. for var_name, variable in section.variables.items():
  520. if variable.required:
  521. logger.debug(
  522. f"Including required variable '{var_name}' from disabled section '{section_key}'"
  523. )
  524. satisfied_values[var_name] = variable.convert(variable.value)
  525. return satisfied_values
  526. def get_sensitive_variables(self) -> dict[str, Any]:
  527. """Get only the sensitive variables with their values."""
  528. return {
  529. name: var.value
  530. for name, var in self._variable_map.items()
  531. if var.sensitive and var.value
  532. }
  533. def apply_defaults(
  534. self, defaults: dict[str, Any], origin: str = "cli"
  535. ) -> list[str]:
  536. """Apply default values to variables, updating their origin.
  537. Args:
  538. defaults: Dictionary mapping variable names to their default values
  539. origin: Source of these defaults (e.g., 'config', 'cli')
  540. Returns:
  541. List of variable names that were successfully updated
  542. """
  543. # NOTE: This method uses the _variable_map for a significant performance gain,
  544. # as it allows direct O(1) lookup of variables instead of iterating
  545. # through all sections to find a match.
  546. successful = []
  547. errors = []
  548. for var_name, value in defaults.items():
  549. try:
  550. variable = self._variable_map.get(var_name)
  551. if not variable:
  552. logger.warning(f"Variable '{var_name}' not found in template")
  553. continue
  554. # Check if this is a toggle variable for a required section
  555. # If trying to set it to false, warn and skip
  556. for section in self._sections.values():
  557. if (
  558. section.required
  559. and section.toggle
  560. and section.toggle == var_name
  561. ):
  562. # Convert value to bool to check if it's false
  563. try:
  564. bool_value = variable.convert(value)
  565. if not bool_value:
  566. logger.warning(
  567. f"Ignoring attempt to disable toggle '{var_name}' for required section '{section.key}' via {origin}"
  568. )
  569. continue
  570. except Exception:
  571. pass # If conversion fails, let normal validation handle it
  572. # Check if variable's needs are satisfied
  573. # If not, warn that the override will have no effect
  574. if not self.is_variable_satisfied(var_name):
  575. # Build a friendly message about which needs aren't satisfied
  576. unmet_needs = []
  577. for need in variable.needs:
  578. if not self._is_need_satisfied(need):
  579. unmet_needs.append(need)
  580. needs_str = ", ".join(unmet_needs) if unmet_needs else "unknown"
  581. logger.warning(
  582. f"Setting '{var_name}' via {origin} will have no effect - needs not satisfied: {needs_str}"
  583. )
  584. # Continue anyway to store the value (it might become relevant later)
  585. # Store original value before overriding (for display purposes)
  586. # Only store if this is the first time config is being applied
  587. if origin == "config" and not hasattr(variable, "_original_stored"):
  588. variable.original_value = variable.value
  589. variable._original_stored = True
  590. # Convert and set the new value
  591. converted_value = variable.convert(value)
  592. variable.value = converted_value
  593. # Set origin to the current source (not a chain)
  594. variable.origin = origin
  595. successful.append(var_name)
  596. except ValueError as e:
  597. error_msg = f"Invalid value for '{var_name}': {value} - {e}"
  598. errors.append(error_msg)
  599. logger.error(error_msg)
  600. if errors:
  601. logger.warning(f"Some defaults failed to apply: {'; '.join(errors)}")
  602. return successful
  603. def validate_all(self) -> None:
  604. """Validate all variables in the collection.
  605. Validates:
  606. - All variables in enabled sections with satisfied dependencies
  607. - Required variables even if their section is disabled (but dependencies must be satisfied)
  608. - CLI-provided bool variables with unsatisfied dependencies
  609. """
  610. errors: list[str] = []
  611. # First, check for CLI-provided bool variables with unsatisfied dependencies
  612. for section_key, section in self._sections.items():
  613. section_satisfied = self.is_section_satisfied(section_key)
  614. is_enabled = section.is_enabled()
  615. for var_name, variable in section.variables.items():
  616. # Check CLI-provided bool variables with unsatisfied dependencies
  617. if (
  618. variable.type == "bool"
  619. and variable.origin == "cli"
  620. and variable.value is not False
  621. ):
  622. var_satisfied = self.is_variable_satisfied(var_name)
  623. if not section_satisfied or not is_enabled or not var_satisfied:
  624. # Build error message with unmet needs (use set to avoid duplicates)
  625. unmet_needs = set()
  626. if not section_satisfied:
  627. for need in section.needs:
  628. if not self._is_need_satisfied(need):
  629. unmet_needs.add(need)
  630. if not var_satisfied:
  631. for need in variable.needs:
  632. if not self._is_need_satisfied(need):
  633. unmet_needs.add(need)
  634. needs_str = (
  635. ", ".join(sorted(unmet_needs))
  636. if unmet_needs
  637. else "dependencies not satisfied"
  638. )
  639. errors.append(
  640. f"{section.key}.{var_name} (set via CLI to {variable.value} but requires: {needs_str})"
  641. )
  642. # Then validate all other variables
  643. for section_key, section in self._sections.items():
  644. # Skip sections with unsatisfied dependencies (even for required variables)
  645. if not self.is_section_satisfied(section_key):
  646. logger.debug(
  647. f"Skipping validation for section '{section_key}' - dependencies not satisfied"
  648. )
  649. continue
  650. # Check if section is enabled
  651. is_enabled = section.is_enabled()
  652. if not is_enabled:
  653. logger.debug(
  654. f"Section '{section_key}' is disabled - validating only required variables"
  655. )
  656. # Validate variables in the section
  657. for var_name, variable in section.variables.items():
  658. # Skip all variables (including required ones) in disabled sections
  659. # Required variables are only required when their section is actually enabled
  660. if not is_enabled:
  661. continue
  662. try:
  663. # Skip autogenerated variables when empty
  664. if variable.autogenerated and not variable.value:
  665. continue
  666. # Check required fields
  667. if variable.value is None:
  668. # Optional variables can be None/empty
  669. if hasattr(variable, "optional") and variable.optional:
  670. continue
  671. if variable.is_required():
  672. errors.append(
  673. f"{section.key}.{var_name} (required - no default provided)"
  674. )
  675. continue
  676. # Validate typed value
  677. typed = variable.convert(variable.value)
  678. if variable.type not in ("bool",) and not typed:
  679. msg = f"{section.key}.{var_name}"
  680. errors.append(
  681. f"{msg} (required - cannot be empty)"
  682. if variable.is_required()
  683. else f"{msg} (empty)"
  684. )
  685. except ValueError as e:
  686. errors.append(f"{section.key}.{var_name} (invalid format: {e})")
  687. if errors:
  688. error_msg = "Variable validation failed: " + ", ".join(errors)
  689. logger.error(error_msg)
  690. raise VariableValidationError("__multiple__", ", ".join(errors))
  691. def merge(
  692. self,
  693. other_spec: dict[str, Any] | VariableCollection,
  694. origin: str = "override",
  695. ) -> VariableCollection:
  696. """Merge another spec or VariableCollection into this one with precedence tracking.
  697. OPTIMIZED: Works directly on objects without dict conversions for better performance.
  698. The other spec/collection has higher precedence and will override values in self.
  699. Creates a new VariableCollection with merged data.
  700. Args:
  701. other_spec: Either a spec dictionary or another VariableCollection to merge
  702. origin: Origin label for variables from other_spec (e.g., 'template', 'config')
  703. Returns:
  704. New VariableCollection with merged data
  705. Example:
  706. module_vars = VariableCollection(module_spec)
  707. template_vars = module_vars.merge(template_spec, origin='template')
  708. # Variables from template_spec override module_spec
  709. # Origins tracked: 'module' or 'module -> template'
  710. """
  711. # Convert dict to VariableCollection if needed (only once)
  712. if isinstance(other_spec, dict):
  713. other = VariableCollection(other_spec)
  714. else:
  715. other = other_spec
  716. # Create new collection without calling __init__ (optimization)
  717. merged = VariableCollection.__new__(VariableCollection)
  718. merged._sections = {}
  719. merged._variable_map = {}
  720. # First pass: clone sections from self
  721. for section_key, self_section in self._sections.items():
  722. if section_key in other._sections:
  723. # Section exists in both - will merge
  724. merged._sections[section_key] = self._merge_sections(
  725. self_section, other._sections[section_key], origin
  726. )
  727. else:
  728. # Section only in self - clone it
  729. merged._sections[section_key] = self_section.clone()
  730. # Second pass: add sections that only exist in other
  731. for section_key, other_section in other._sections.items():
  732. if section_key not in merged._sections:
  733. # New section from other - clone with origin update
  734. merged._sections[section_key] = other_section.clone(
  735. origin_update=origin
  736. )
  737. # Rebuild variable map for O(1) lookups
  738. for section in merged._sections.values():
  739. for var_name, variable in section.variables.items():
  740. merged._variable_map[var_name] = variable
  741. # Validate dependencies after merge is complete
  742. merged._validate_dependencies()
  743. return merged
  744. def _merge_sections(
  745. self, self_section: VariableSection, other_section: VariableSection, origin: str
  746. ) -> VariableSection:
  747. """Merge two sections, with other_section taking precedence."""
  748. merged_section = self_section.clone()
  749. # Update section metadata from other (other takes precedence)
  750. # Explicit null/empty values clear the property (reset mechanism)
  751. for attr in ("title", "description", "toggle"):
  752. if (
  753. hasattr(other_section, "_explicit_fields")
  754. and attr in other_section._explicit_fields
  755. ):
  756. # Set to the other value even if null/empty (enables explicit reset)
  757. setattr(merged_section, attr, getattr(other_section, attr))
  758. merged_section.required = other_section.required
  759. # Respect explicit clears for dependencies (explicit null/empty clears, missing field preserves)
  760. if (
  761. hasattr(other_section, "_explicit_fields")
  762. and "needs" in other_section._explicit_fields
  763. ):
  764. merged_section.needs = (
  765. other_section.needs.copy() if other_section.needs else []
  766. )
  767. # Merge variables
  768. for var_name, other_var in other_section.variables.items():
  769. if var_name in merged_section.variables:
  770. # Variable exists in both - merge with other taking precedence
  771. self_var = merged_section.variables[var_name]
  772. # Build update dict with ONLY explicitly provided fields from other
  773. update = {"origin": origin}
  774. field_map = {
  775. "type": other_var.type,
  776. "description": other_var.description,
  777. "prompt": other_var.prompt,
  778. "options": other_var.options,
  779. "sensitive": other_var.sensitive,
  780. "extra": other_var.extra,
  781. }
  782. # Add fields that were explicitly provided, even if falsy/empty
  783. for field, value in field_map.items():
  784. if field in other_var._explicit_fields:
  785. update[field] = value
  786. # For boolean flags, only copy if explicitly provided in other
  787. # This prevents False defaults from overriding True values
  788. for bool_field in ("optional", "autogenerated", "required"):
  789. if bool_field in other_var._explicit_fields:
  790. update[bool_field] = getattr(other_var, bool_field)
  791. # Special handling for needs (allow explicit null/empty to clear)
  792. if "needs" in other_var._explicit_fields:
  793. update["needs"] = other_var.needs.copy() if other_var.needs else []
  794. # Special handling for value/default (allow explicit null to clear)
  795. if (
  796. "value" in other_var._explicit_fields
  797. or "default" in other_var._explicit_fields
  798. ):
  799. update["value"] = other_var.value
  800. merged_section.variables[var_name] = self_var.clone(update=update)
  801. else:
  802. # New variable from other - clone with origin
  803. merged_section.variables[var_name] = other_var.clone(
  804. update={"origin": origin}
  805. )
  806. return merged_section
  807. def filter_to_used(
  808. self, used_variables: set[str], keep_sensitive: bool = True
  809. ) -> VariableCollection:
  810. """Filter collection to only variables that are used (or sensitive).
  811. OPTIMIZED: Works directly on objects without dict conversions for better performance.
  812. Creates a new VariableCollection containing only the variables in used_variables.
  813. Sections with no remaining variables are removed.
  814. Args:
  815. used_variables: Set of variable names that are actually used
  816. keep_sensitive: If True, also keep sensitive variables even if not in used set
  817. Returns:
  818. New VariableCollection with filtered variables
  819. Example:
  820. all_vars = VariableCollection(spec)
  821. used_vars = all_vars.filter_to_used({'var1', 'var2', 'var3'})
  822. # Only var1, var2, var3 (and any sensitive vars) remain
  823. """
  824. # Create new collection without calling __init__ (optimization)
  825. filtered = VariableCollection.__new__(VariableCollection)
  826. filtered._sections = {}
  827. filtered._variable_map = {}
  828. # Filter each section
  829. for section_key, section in self._sections.items():
  830. # Create a new section with same metadata
  831. filtered_section = VariableSection(
  832. {
  833. "key": section.key,
  834. "title": section.title,
  835. "description": section.description,
  836. "toggle": section.toggle,
  837. "required": section.required,
  838. "needs": section.needs.copy() if section.needs else None,
  839. }
  840. )
  841. # Clone only the variables that should be included
  842. for var_name, variable in section.variables.items():
  843. # Include if used OR if sensitive (and keep_sensitive is True)
  844. should_include = var_name in used_variables or (
  845. keep_sensitive and variable.sensitive
  846. )
  847. if should_include:
  848. filtered_section.variables[var_name] = variable.clone()
  849. # Only add section if it has variables
  850. if filtered_section.variables:
  851. filtered._sections[section_key] = filtered_section
  852. # Add variables to map
  853. for var_name, variable in filtered_section.variables.items():
  854. filtered._variable_map[var_name] = variable
  855. return filtered
  856. def get_all_variable_names(self) -> set[str]:
  857. """Get set of all variable names across all sections.
  858. Returns:
  859. Set of all variable names
  860. """
  861. return set(self._variable_map.keys())