dependency_matrix.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. """Build dependency-aware template validation cases."""
  2. from __future__ import annotations
  3. import itertools
  4. import json
  5. from dataclasses import dataclass
  6. from typing import TYPE_CHECKING, Any
  7. if TYPE_CHECKING:
  8. from cli.core.template import Template
  9. from cli.core.template.variable import Variable
  10. from cli.core.template.variable_collection import VariableCollection
  11. @dataclass(frozen=True)
  12. class MatrixOptions:
  13. """Options controlling dependency matrix generation."""
  14. max_combinations: int = 100
  15. @dataclass(frozen=True)
  16. class DependencyCondition:
  17. """Structured representation of a needs condition."""
  18. variable: str
  19. positive: bool
  20. values: tuple[str, ...] | None = None
  21. @dataclass
  22. class ValidationCase:
  23. """A named variable configuration to render and validate."""
  24. name: str
  25. variables: VariableCollection
  26. overrides: dict[str, Any]
  27. class DependencyMatrixBuilder:
  28. """Create practical dependency-aware validation cases for one template."""
  29. def __init__(self, template: Template, options: MatrixOptions | None = None) -> None:
  30. self.template = template
  31. self.options = options or MatrixOptions()
  32. self.base_variables = template.variables
  33. self._conditions = self._collect_conditions()
  34. def build(self) -> list[ValidationCase]:
  35. """Build named, satisfiable, deduplicated validation cases."""
  36. variable_values = self._build_branch_value_sets()
  37. raw_cases = self._build_raw_cases(variable_values)
  38. return self._materialize_cases(raw_cases)
  39. def _collect_conditions(self) -> list[DependencyCondition]:
  40. conditions: list[DependencyCondition] = []
  41. for section in self.base_variables.get_sections().values():
  42. conditions.extend(self._parse_conditions(section.needs))
  43. for variable in section.variables.values():
  44. conditions.extend(self._parse_conditions(variable.needs))
  45. return conditions
  46. def _parse_conditions(self, needs: list[str]) -> list[DependencyCondition]:
  47. conditions = []
  48. for need in needs:
  49. condition = self._parse_condition(need)
  50. if condition is not None:
  51. conditions.append(condition)
  52. return conditions
  53. @staticmethod
  54. def _parse_condition(need: str) -> DependencyCondition | None:
  55. if "!=" in need:
  56. variable, raw_values = need.split("!=", 1)
  57. return DependencyCondition(variable.strip(), False, DependencyMatrixBuilder._split_values(raw_values))
  58. if "=" in need:
  59. variable, raw_values = need.split("=", 1)
  60. return DependencyCondition(variable.strip(), True, DependencyMatrixBuilder._split_values(raw_values))
  61. return None
  62. @staticmethod
  63. def _split_values(raw_values: str) -> tuple[str, ...]:
  64. return tuple(value.strip() for value in raw_values.split(",") if value.strip())
  65. def _build_branch_value_sets(self) -> dict[str, list[Any]]:
  66. value_sets: dict[str, list[Any]] = {}
  67. variables = self.base_variables._variable_map
  68. # Bool variables are cheap and often control template branches directly.
  69. for name, variable in variables.items():
  70. if variable.type == "bool":
  71. value_sets[name] = [False, True]
  72. # Section toggles may not follow a naming convention, so include them explicitly.
  73. for section in self.base_variables.get_sections().values():
  74. if section.toggle and section.toggle in variables:
  75. value_sets[section.toggle] = [False, True]
  76. for condition in self._conditions:
  77. variable = variables.get(condition.variable)
  78. if variable is None:
  79. continue
  80. values = self._condition_values(variable, condition)
  81. if not values:
  82. continue
  83. current_values = value_sets.setdefault(condition.variable, [])
  84. for value in values:
  85. if value not in current_values:
  86. current_values.append(value)
  87. return {name: values for name, values in value_sets.items() if values}
  88. def _condition_values(self, variable: Variable, condition: DependencyCondition) -> list[Any]:
  89. values: list[Any] = []
  90. if variable.type == "bool":
  91. return [False, True]
  92. if variable.type == "enum":
  93. values.extend(self._matching_enum_values(variable, condition))
  94. if variable.value is not None and variable.value not in values:
  95. values.append(variable.value)
  96. return values
  97. if condition.values:
  98. values.extend(condition.values)
  99. if variable.value is not None and variable.value not in values:
  100. values.append(variable.value)
  101. return values
  102. @staticmethod
  103. def _matching_enum_values(variable: Variable, condition: DependencyCondition) -> list[str]:
  104. options = list(variable.options or [])
  105. if not condition.values:
  106. return options
  107. if condition.positive:
  108. return [value for value in condition.values if value in options]
  109. excluded = set(condition.values)
  110. return [value for value in options if value not in excluded]
  111. def _build_raw_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
  112. if not value_sets:
  113. return [("defaults", {})]
  114. count = 1
  115. for values in value_sets.values():
  116. count *= len(values)
  117. if count <= self.options.max_combinations:
  118. return self._cartesian_cases(value_sets)
  119. return self._reduced_cases(value_sets)
  120. def _cartesian_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
  121. names = sorted(value_sets)
  122. cases: list[tuple[str, dict[str, Any]]] = [("defaults", {})]
  123. for index, values in enumerate(itertools.product(*(value_sets[name] for name in names)), start=1):
  124. overrides = dict(zip(names, values, strict=True))
  125. cases.append((self._case_name(f"matrix-{index}", overrides), overrides))
  126. return cases
  127. def _reduced_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
  128. cases: list[tuple[str, dict[str, Any]]] = [("defaults", {})]
  129. bool_names = sorted(name for name in value_sets if self.base_variables._variable_map.get(name).type == "bool")
  130. if bool_names:
  131. cases.append(("all-bools-false", dict.fromkeys(bool_names, False)))
  132. cases.append(("all-bools-true", dict.fromkeys(bool_names, True)))
  133. for name in sorted(value_sets):
  134. for value in value_sets[name]:
  135. overrides = {name: value}
  136. cases.append((self._case_name("branch", overrides), overrides))
  137. return cases
  138. def _materialize_cases(self, raw_cases: list[tuple[str, dict[str, Any]]]) -> list[ValidationCase]:
  139. cases: list[ValidationCase] = []
  140. seen_effective_states: set[str] = set()
  141. for name, overrides in raw_cases:
  142. variables = self._fresh_variables()
  143. if overrides:
  144. variables.apply_defaults(overrides, origin="matrix")
  145. variables.reset_disabled_bool_variables()
  146. state_key = self._state_key(variables.get_satisfied_values())
  147. if state_key in seen_effective_states:
  148. continue
  149. seen_effective_states.add(state_key)
  150. cases.append(ValidationCase(name=name, variables=variables, overrides=overrides))
  151. return cases
  152. def _fresh_variables(self) -> VariableCollection:
  153. return self.base_variables.merge({}, origin="matrix")
  154. @staticmethod
  155. def _state_key(values: dict[str, Any]) -> str:
  156. return json.dumps(values, sort_keys=True, default=str)
  157. @staticmethod
  158. def _case_name(prefix: str, overrides: dict[str, Any]) -> str:
  159. details = ", ".join(f"{key}={value}" for key, value in sorted(overrides.items()))
  160. return f"{prefix}: {details}" if details else prefix