section.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. from __future__ import annotations
  2. from collections import OrderedDict
  3. from typing import Any, Dict, List, Optional
  4. from .variable import Variable
  5. class VariableSection:
  6. """Groups variables together with shared metadata for presentation."""
  7. def __init__(self, data: dict[str, Any]) -> None:
  8. """Initialize VariableSection from a dictionary.
  9. Args:
  10. data: Dictionary containing section specification with required 'key' and 'title' keys
  11. """
  12. if not isinstance(data, dict):
  13. raise ValueError("VariableSection data must be a dictionary")
  14. if "key" not in data:
  15. raise ValueError("VariableSection data must contain 'key'")
  16. if "title" not in data:
  17. raise ValueError("VariableSection data must contain 'title'")
  18. self.key: str = data["key"]
  19. self.title: str = data["title"]
  20. self.variables: OrderedDict[str, Variable] = OrderedDict()
  21. self.description: Optional[str] = data.get("description")
  22. self.toggle: Optional[str] = data.get("toggle")
  23. # Track which fields were explicitly provided (to support explicit clears)
  24. self._explicit_fields: set[str] = set(data.keys())
  25. # Default "general" section to required=True, all others to required=False
  26. self.required: bool = data.get("required", data["key"] == "general")
  27. # Section dependencies - can be string or list of strings
  28. # Supports semicolon-separated multiple conditions: "var1=value1;var2=value2,value3"
  29. needs_value = data.get("needs")
  30. if needs_value:
  31. if isinstance(needs_value, str):
  32. # Split by semicolon to support multiple AND conditions in a single string
  33. # Example: "traefik_enabled=true;network_mode=bridge,macvlan"
  34. self.needs: List[str] = [
  35. need.strip() for need in needs_value.split(";") if need.strip()
  36. ]
  37. elif isinstance(needs_value, list):
  38. self.needs: List[str] = needs_value
  39. else:
  40. raise ValueError(
  41. f"Section '{self.key}' has invalid 'needs' value: must be string or list"
  42. )
  43. else:
  44. self.needs: List[str] = []
  45. def to_dict(self) -> Dict[str, Any]:
  46. """Serialize VariableSection to a dictionary for storage."""
  47. section_dict = {
  48. "required": self.required,
  49. "vars": {name: var.to_dict() for name, var in self.variables.items()},
  50. }
  51. # Add optional fields if present
  52. for field in ("title", "description", "toggle"):
  53. if value := getattr(self, field):
  54. section_dict[field] = value
  55. # Store dependencies (single value if only one, list otherwise)
  56. if self.needs:
  57. section_dict["needs"] = (
  58. self.needs[0] if len(self.needs) == 1 else self.needs
  59. )
  60. return section_dict
  61. def is_enabled(self) -> bool:
  62. """Check if section is currently enabled based on toggle variable.
  63. Returns:
  64. True if section is enabled (required, no toggle, or toggle is True), False otherwise
  65. """
  66. # Required sections are always enabled, regardless of toggle
  67. if self.required:
  68. return True
  69. if not self.toggle:
  70. return True
  71. toggle_var = self.variables.get(self.toggle)
  72. if not toggle_var:
  73. return True
  74. try:
  75. return bool(toggle_var.convert(toggle_var.value))
  76. except Exception:
  77. return False
  78. def clone(self, origin_update: Optional[str] = None) -> "VariableSection":
  79. """Create a deep copy of the section with all variables.
  80. This is more efficient than converting to dict and back when copying sections.
  81. Args:
  82. origin_update: Optional origin string to apply to all cloned variables
  83. Returns:
  84. New VariableSection instance with deep-copied variables
  85. Example:
  86. section2 = section1.clone(origin_update='template')
  87. """
  88. # Create new section with same metadata
  89. cloned = VariableSection(
  90. {
  91. "key": self.key,
  92. "title": self.title,
  93. "description": self.description,
  94. "toggle": self.toggle,
  95. "required": self.required,
  96. "needs": self.needs.copy() if self.needs else None,
  97. }
  98. )
  99. # Deep copy all variables
  100. for var_name, variable in self.variables.items():
  101. if origin_update:
  102. cloned.variables[var_name] = variable.clone(
  103. update={"origin": origin_update}
  104. )
  105. else:
  106. cloned.variables[var_name] = variable.clone()
  107. return cloned
  108. def sort_variables(self, is_need_satisfied_func=None) -> None:
  109. """Sort variables within section for optimal display and user interaction.
  110. Current sorting strategy:
  111. - Variables with no dependencies come first
  112. - Variables that depend on others come after their dependencies (topological sort)
  113. - Original order is preserved for variables at the same dependency level
  114. Future sorting strategies can be added here (e.g., by type, required first, etc.)
  115. Args:
  116. is_need_satisfied_func: Optional function to check if a variable need is satisfied
  117. (reserved for future use in conditional sorting)
  118. """
  119. if not self.variables:
  120. return
  121. # Build dependency graph
  122. var_list = list(self.variables.keys())
  123. var_set = set(var_list)
  124. # For each variable, find which OTHER variables in THIS section it depends on
  125. dependencies = {var_name: [] for var_name in var_list}
  126. for var_name in var_list:
  127. variable = self.variables[var_name]
  128. if variable.needs:
  129. for need in variable.needs:
  130. # Parse need format: "variable_name=value"
  131. dep_var = need.split("=")[0] if "=" in need else need
  132. # Only track dependencies within THIS section
  133. if dep_var in var_set and dep_var != var_name:
  134. dependencies[var_name].append(dep_var)
  135. # Topological sort using Kahn's algorithm
  136. in_degree = {var_name: len(deps) for var_name, deps in dependencies.items()}
  137. queue = [var for var, degree in in_degree.items() if degree == 0]
  138. # Preserve original order for variables with same dependency level
  139. queue.sort(key=lambda v: var_list.index(v))
  140. result = []
  141. while queue:
  142. current = queue.pop(0)
  143. result.append(current)
  144. # Update in-degree for dependent variables
  145. for var_name, deps in dependencies.items():
  146. if current in deps:
  147. in_degree[var_name] -= 1
  148. if in_degree[var_name] == 0:
  149. queue.append(var_name)
  150. queue.sort(key=lambda v: var_list.index(v))
  151. # If not all variables were sorted (cycle), append remaining in original order
  152. if len(result) != len(var_list):
  153. for var_name in var_list:
  154. if var_name not in result:
  155. result.append(var_name)
  156. # Rebuild variables OrderedDict in new order
  157. sorted_vars = OrderedDict()
  158. for var_name in result:
  159. sorted_vars[var_name] = self.variables[var_name]
  160. self.variables = sorted_vars