Files
2026-05-04 16:32:30 +02:00

217 lines
7.9 KiB
Python

"""Build dependency-aware template validation cases."""
from __future__ import annotations
import itertools
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from cli.core.template import Template
from cli.core.template.variable import Variable
from cli.core.template.variable_collection import VariableCollection
@dataclass(frozen=True)
class MatrixOptions:
"""Options controlling dependency matrix generation."""
max_combinations: int = 100
@dataclass(frozen=True)
class DependencyCondition:
"""Structured representation of a needs condition."""
variable: str
positive: bool
values: tuple[str, ...] | None = None
@dataclass
class ValidationCase:
"""A named variable configuration to render and validate."""
name: str
variables: VariableCollection
overrides: dict[str, Any]
class DependencyMatrixBuilder:
"""Create practical dependency-aware validation cases for one template."""
def __init__(self, template: Template, options: MatrixOptions | None = None) -> None:
self.template = template
self.options = options or MatrixOptions()
self.base_variables = template.variables
self._conditions = self._collect_conditions()
def build(self) -> list[ValidationCase]:
"""Build named, satisfiable, deduplicated validation cases."""
variable_values = self._build_branch_value_sets()
raw_cases = self._build_raw_cases(variable_values)
return self._materialize_cases(raw_cases)
def _collect_conditions(self) -> list[DependencyCondition]:
conditions: list[DependencyCondition] = []
for section in self.base_variables.get_sections().values():
conditions.extend(self._parse_conditions(section.needs))
for variable in section.variables.values():
conditions.extend(self._parse_conditions(variable.needs))
return conditions
def _parse_conditions(self, needs: list[str]) -> list[DependencyCondition]:
conditions = []
for need in needs:
condition = self._parse_condition(need)
if condition is not None:
conditions.append(condition)
return conditions
@staticmethod
def _parse_condition(need: str) -> DependencyCondition | None:
if "!=" in need:
variable, raw_values = need.split("!=", 1)
return DependencyCondition(variable.strip(), False, DependencyMatrixBuilder._split_values(raw_values))
if "=" in need:
variable, raw_values = need.split("=", 1)
return DependencyCondition(variable.strip(), True, DependencyMatrixBuilder._split_values(raw_values))
return None
@staticmethod
def _split_values(raw_values: str) -> tuple[str, ...]:
return tuple(value.strip() for value in raw_values.split(",") if value.strip())
def _build_branch_value_sets(self) -> dict[str, list[Any]]:
value_sets: dict[str, list[Any]] = {}
variables = self.base_variables._variable_map
# Bool variables are cheap and often control template branches directly.
for name, variable in variables.items():
if variable.type == "bool":
value_sets[name] = [False, True]
# Section toggles may not follow a naming convention, so include them explicitly.
for section in self.base_variables.get_sections().values():
if section.toggle and section.toggle in variables:
value_sets[section.toggle] = [False, True]
for condition in self._conditions:
variable = variables.get(condition.variable)
if variable is None:
continue
values = self._condition_values(variable, condition)
if not values:
continue
current_values = value_sets.setdefault(condition.variable, [])
for value in values:
if value not in current_values:
current_values.append(value)
return {name: values for name, values in value_sets.items() if values}
def _condition_values(self, variable: Variable, condition: DependencyCondition) -> list[Any]:
values: list[Any] = []
if variable.type == "bool":
return [False, True]
if variable.type == "enum":
values.extend(self._matching_enum_values(variable, condition))
if variable.value is not None and variable.value not in values:
values.append(variable.value)
return values
if condition.values:
values.extend(condition.values)
if variable.value is not None and variable.value not in values:
values.append(variable.value)
return values
@staticmethod
def _matching_enum_values(variable: Variable, condition: DependencyCondition) -> list[str]:
options = list(variable.options or [])
if not condition.values:
return options
if condition.positive:
return [value for value in condition.values if value in options]
excluded = set(condition.values)
return [value for value in options if value not in excluded]
def _build_raw_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
if not value_sets:
return [("defaults", {})]
count = 1
for values in value_sets.values():
count *= len(values)
if count <= self.options.max_combinations:
return self._cartesian_cases(value_sets)
return self._reduced_cases(value_sets)
def _cartesian_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
names = sorted(value_sets)
cases: list[tuple[str, dict[str, Any]]] = [("defaults", {})]
for index, values in enumerate(itertools.product(*(value_sets[name] for name in names)), start=1):
overrides = dict(zip(names, values, strict=True))
cases.append((self._case_name(f"matrix-{index}", overrides), overrides))
return cases
def _reduced_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
cases: list[tuple[str, dict[str, Any]]] = [("defaults", {})]
bool_names = sorted(name for name in value_sets if self.base_variables._variable_map.get(name).type == "bool")
if bool_names:
cases.append(("all-bools-false", dict.fromkeys(bool_names, False)))
cases.append(("all-bools-true", dict.fromkeys(bool_names, True)))
for name in sorted(value_sets):
for value in value_sets[name]:
overrides = {name: value}
cases.append((self._case_name("branch", overrides), overrides))
return cases
def _materialize_cases(self, raw_cases: list[tuple[str, dict[str, Any]]]) -> list[ValidationCase]:
cases: list[ValidationCase] = []
seen_effective_states: set[str] = set()
for name, overrides in raw_cases:
variables = self._fresh_variables()
if overrides:
variables.apply_defaults(overrides, origin="matrix")
variables.reset_disabled_bool_variables()
state_key = self._state_key(variables.get_satisfied_values())
if state_key in seen_effective_states:
continue
seen_effective_states.add(state_key)
cases.append(ValidationCase(name=name, variables=variables, overrides=overrides))
return cases
def _fresh_variables(self) -> VariableCollection:
return self.base_variables.merge({}, origin="matrix")
@staticmethod
def _state_key(values: dict[str, Any]) -> str:
return json.dumps(values, sort_keys=True, default=str)
@staticmethod
def _case_name(prefix: str, overrides: dict[str, Any]) -> str:
details = ", ".join(f"{key}={value}" for key, value in sorted(overrides.items()))
return f"{prefix}: {details}" if details else prefix