| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697 |
- from __future__ import annotations
- from dataclasses import dataclass
- from typing import TYPE_CHECKING, Any
- from urllib.parse import urlparse
- from email_validator import EmailNotValidError, validate_email
- if TYPE_CHECKING:
- from cli.core.template.variable_section import VariableSection
- DEFAULT_AUTOGENERATED_LENGTH = 32
- DEFAULT_AUTOGENERATED_BYTES = 32
- TRUE_VALUES = {"true", "1", "yes", "on"}
- FALSE_VALUES = {"false", "0", "no", "off"}
- SECRET_TYPE = "secret"
- SECRET_AUTOGENERATED_KIND_CHARACTERS = "characters"
- SECRET_AUTOGENERATED_KIND_BASE64 = "base64"
- @dataclass
- class SecretAutogeneratedConfig:
- """Structured autogeneration settings for secret variables."""
- kind: str = SECRET_AUTOGENERATED_KIND_CHARACTERS
- length: int | None = None
- characters: list[str] | None = None
- bytes: int | None = None
- def clone(self) -> SecretAutogeneratedConfig:
- return SecretAutogeneratedConfig(
- kind=self.kind,
- length=self.length,
- characters=self.characters.copy() if self.characters else None,
- bytes=self.bytes,
- )
- def length_or_default(self) -> int:
- return self.length if self.length is not None else DEFAULT_AUTOGENERATED_LENGTH
- def bytes_or_default(self) -> int:
- return self.bytes if self.bytes is not None else DEFAULT_AUTOGENERATED_BYTES
- @dataclass
- class VariableConfig:
- """Type-specific variable configuration."""
- placeholder: str | None = None
- textarea: bool = False
- unit: str | None = None
- options: list[str] | None = None
- slider: bool = False
- min: int | None = None
- max: int | None = None
- step: int | None = None
- autogenerated: SecretAutogeneratedConfig | None = None
- def clone(self) -> VariableConfig:
- return VariableConfig(
- placeholder=self.placeholder,
- textarea=self.textarea,
- unit=self.unit,
- options=self.options.copy() if self.options else None,
- slider=self.slider,
- min=self.min,
- max=self.max,
- step=self.step,
- autogenerated=self.autogenerated.clone() if self.autogenerated else None,
- )
- def is_empty(self) -> bool:
- return (
- not self.placeholder
- and not self.textarea
- and not self.unit
- and not self.options
- and not self.slider
- and self.min is None
- and self.max is None
- and self.step is None
- and self.autogenerated is None
- )
- class Variable:
- """Represents a single templating variable with lightweight validation."""
- def __init__(self, data: dict[str, Any]) -> None:
- self._validate_input_data(data)
- self._explicit_fields: set[str] = set(data.keys())
- self.name: str = data["name"]
- self.parent_section: VariableSection | None = data.get("parent_section")
- self.description: str | None = data.get("description") or data.get("display", "")
- self.type: str = data.get("type", "str")
- self.prompt: str | None = data.get("prompt")
- self.value: Any = self._resolve_initial_value(data)
- self.origin: str | None = data.get("origin")
- self.extra: str | None = data.get("extra")
- self.required: bool = data.get("required", False)
- self.original_value: Any | None = data.get("original_value")
- self.config = self._normalize_config(self.name, self.type, data.get("config"))
- self._apply_config_state()
- self._validate_secret_defaults(data)
- self.needs = self._parse_needs(data.get("needs"))
- self._validate_initial_value()
- @staticmethod
- def _validate_input_data(data: dict[str, Any]) -> None:
- if not isinstance(data, dict):
- raise ValueError("Variable data must be a dictionary")
- if "name" not in data:
- raise ValueError("Variable data must contain 'name' key")
- def _resolve_initial_value(self, data: dict[str, Any]) -> Any:
- if "value" in data:
- return data.get("value")
- if "default" in data:
- return data.get("default")
- return False if self.type == "bool" else None
- def _apply_config_state(self) -> None:
- self.options: list[str] | None = self.config.options.copy() if self.config.options else None
- self.autogenerated_config: SecretAutogeneratedConfig | None = (
- self.config.autogenerated.clone() if self.config.autogenerated else None
- )
- self.autogenerated: bool = self.autogenerated_config is not None
- self.autogenerated_length: int = (
- self.autogenerated_config.length_or_default() if self.autogenerated_config else DEFAULT_AUTOGENERATED_LENGTH
- )
- self.autogenerated_base64: bool = bool(
- self.autogenerated_config and self.autogenerated_config.kind == SECRET_AUTOGENERATED_KIND_BASE64
- )
- def _validate_secret_defaults(self, data: dict[str, Any]) -> None:
- if self.type == SECRET_TYPE and self.autogenerated and "default" in data:
- raise ValueError(
- f"Invalid default for variable '{self.name}': autogenerated secrets cannot define defaults"
- )
- def _parse_needs(self, needs_value: Any) -> list[str]:
- if not needs_value:
- return []
- if isinstance(needs_value, str):
- return [need.strip() for need in needs_value.split(";") if need.strip()]
- if isinstance(needs_value, list):
- return needs_value
- raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list")
- def _validate_initial_value(self) -> None:
- if self.value is None:
- return
- try:
- self.value = self.convert(self.value)
- if self.type == "int" and self.value is not None and self.config.slider:
- self._validate_slider_value(self.value)
- except ValueError as exc:
- raise ValueError(f"Invalid default for variable '{self.name}': {exc}") from exc
- @staticmethod
- def _normalize_str_list(values: list[Any] | None) -> list[str] | None:
- if not values:
- return None
- normalized: list[str] = []
- seen: set[str] = set()
- for value in values:
- item = str(value).strip()
- if not item or item in seen:
- continue
- seen.add(item)
- normalized.append(item)
- return normalized or None
- @classmethod
- def _parse_secret_autogenerated(
- cls,
- variable_name: str,
- autogenerated_input: Any,
- legacy_length: Any = None,
- legacy_base64: bool = False,
- ) -> SecretAutogeneratedConfig | None:
- if autogenerated_input in (None, False):
- return cls._legacy_secret_autogenerated(legacy_length, legacy_base64)
- if autogenerated_input is True:
- return cls._boolean_secret_autogenerated(variable_name, legacy_length, legacy_base64)
- if not isinstance(autogenerated_input, dict):
- raise ValueError("autogenerated must be a boolean or object")
- config = cls._dict_secret_autogenerated(autogenerated_input, legacy_length, legacy_base64)
- return cls._validate_secret_autogenerated(variable_name, config)
- @staticmethod
- def _legacy_secret_autogenerated(
- legacy_length: Any,
- legacy_base64: bool,
- ) -> SecretAutogeneratedConfig | None:
- if not legacy_base64:
- return None
- return SecretAutogeneratedConfig(
- kind=SECRET_AUTOGENERATED_KIND_BASE64,
- bytes=int(legacy_length) if legacy_length is not None else DEFAULT_AUTOGENERATED_BYTES,
- )
- @classmethod
- def _boolean_secret_autogenerated(
- cls,
- variable_name: str,
- legacy_length: Any,
- legacy_base64: bool,
- ) -> SecretAutogeneratedConfig:
- legacy_config = cls._legacy_secret_autogenerated(legacy_length, legacy_base64)
- if legacy_config is not None:
- return legacy_config
- config = SecretAutogeneratedConfig()
- if legacy_length is not None:
- config.length = int(legacy_length)
- return cls._validate_secret_autogenerated(variable_name, config)
- @classmethod
- def _dict_secret_autogenerated(
- cls,
- autogenerated_input: dict[str, Any],
- legacy_length: Any,
- legacy_base64: bool,
- ) -> SecretAutogeneratedConfig:
- kind = str(
- autogenerated_input.get("kind")
- or (SECRET_AUTOGENERATED_KIND_BASE64 if legacy_base64 else SECRET_AUTOGENERATED_KIND_CHARACTERS)
- ).strip()
- config = SecretAutogeneratedConfig(kind=kind)
- if autogenerated_input.get("length") is not None:
- config.length = int(autogenerated_input["length"])
- elif legacy_length is not None and kind != SECRET_AUTOGENERATED_KIND_BASE64:
- config.length = int(legacy_length)
- if autogenerated_input.get("bytes") is not None:
- config.bytes = int(autogenerated_input["bytes"])
- if autogenerated_input.get("characters") is not None:
- config.characters = cls._normalize_secret_characters(autogenerated_input["characters"])
- return config
- @staticmethod
- def _normalize_secret_characters(characters: Any) -> list[str] | None:
- if not isinstance(characters, list):
- raise ValueError("autogenerated.characters must be a list")
- normalized_characters = []
- seen: set[str] = set()
- for char in characters:
- item = str(char).strip()
- if not item:
- continue
- if len(item) != 1:
- raise ValueError("autogenerated.characters entries must each be exactly one character")
- if item in seen:
- continue
- seen.add(item)
- normalized_characters.append(item)
- return normalized_characters or None
- @staticmethod
- def _validate_secret_autogenerated(
- variable_name: str,
- config: SecretAutogeneratedConfig,
- ) -> SecretAutogeneratedConfig:
- kind = (config.kind or SECRET_AUTOGENERATED_KIND_CHARACTERS).strip()
- if kind not in (SECRET_AUTOGENERATED_KIND_CHARACTERS, SECRET_AUTOGENERATED_KIND_BASE64):
- raise ValueError(
- f"variable '{variable_name}' autogenerated.kind must be one of "
- f"'{SECRET_AUTOGENERATED_KIND_CHARACTERS}' or '{SECRET_AUTOGENERATED_KIND_BASE64}'"
- )
- config.kind = kind
- if kind == SECRET_AUTOGENERATED_KIND_CHARACTERS:
- if config.bytes is not None:
- raise ValueError(f"variable '{variable_name}' character autogenerated secrets cannot define bytes")
- if config.length is not None and config.length <= 0:
- raise ValueError(f"variable '{variable_name}' autogenerated.length must be greater than 0")
- if config.characters is not None and not config.characters:
- raise ValueError(f"variable '{variable_name}' autogenerated.characters must not be empty")
- return config
- if config.length is not None:
- raise ValueError(
- f"variable '{variable_name}' base64 autogenerated secrets must use bytes instead of length"
- )
- if config.characters is not None:
- raise ValueError(f"variable '{variable_name}' base64 autogenerated secrets cannot define characters")
- if config.bytes is not None and config.bytes <= 0:
- raise ValueError(f"variable '{variable_name}' autogenerated.bytes must be greater than 0")
- return config
- @classmethod
- def _normalize_config(
- cls,
- variable_name: str,
- variable_type: str,
- config_input: Any,
- ) -> VariableConfig:
- if config_input is None:
- config_data: dict[str, Any] = {}
- elif isinstance(config_input, dict):
- config_data = config_input.copy()
- else:
- raise ValueError(f"Variable '{variable_name}' config must be a dictionary")
- options = cls._normalize_str_list(config_data.get("options"))
- placeholder = config_data.get("placeholder")
- placeholder = str(placeholder).strip() if placeholder not in (None, "") else None
- textarea = bool(config_data.get("textarea", False))
- unit = config_data.get("unit")
- unit = str(unit).strip() if unit not in (None, "") else None
- slider = bool(config_data.get("slider", False))
- min_value = config_data.get("min")
- max_value = config_data.get("max")
- step_value = config_data.get("step")
- min_int = int(min_value) if min_value is not None else None
- max_int = int(max_value) if max_value is not None else None
- step_int = int(step_value) if step_value is not None else None
- autogenerated_input = config_data.get("autogenerated")
- autogenerated_config = None
- if autogenerated_input not in (None, False) and variable_type != SECRET_TYPE:
- raise ValueError("autogenerated is only supported for secret variables")
- if variable_type == SECRET_TYPE:
- autogenerated_config = cls._parse_secret_autogenerated(
- variable_name,
- autogenerated_input,
- )
- config = VariableConfig(
- placeholder=placeholder,
- textarea=textarea,
- unit=unit,
- options=options,
- slider=slider,
- min=min_int,
- max=max_int,
- step=step_int,
- autogenerated=autogenerated_config,
- )
- if variable_type == "enum" and not config.options:
- raise ValueError("enum variables require non-empty options")
- if variable_type == "int" and config.slider:
- if config.min is None or config.max is None:
- raise ValueError("slider variables require min and max")
- if config.max < config.min:
- raise ValueError("slider variables require max >= min")
- if config.step is not None and config.step <= 0:
- raise ValueError("slider variables require step > 0")
- return config
- def is_secret(self) -> bool:
- return self.type == SECRET_TYPE
- def convert(self, value: Any) -> Any:
- if value is None:
- return None
- if isinstance(value, str) and value.strip() == "":
- return None
- converters = {
- "bool": self._convert_bool,
- "int": self._convert_int,
- "float": self._convert_float,
- "enum": self._convert_enum,
- "url": self._convert_url,
- "email": self._convert_email,
- }
- converter = converters.get(self.type)
- if converter:
- return converter(value)
- return str(value)
- def validate_and_convert(self, value: Any, check_required: bool = True) -> Any:
- converted = self.convert(value)
- if self.autogenerated and (converted is None or (isinstance(converted, str) and converted in {"", "*auto"})):
- return None
- if (
- check_required
- and self.is_required()
- and (converted is None or (isinstance(converted, str) and converted == ""))
- ):
- raise ValueError("This field is required and cannot be empty")
- if self.type == "int" and converted is not None and self.config.slider:
- self._validate_slider_value(converted)
- return converted
- def _convert_bool(self, value: Any) -> bool:
- if isinstance(value, bool):
- return value
- if isinstance(value, str):
- lowered = value.strip().lower()
- if lowered in TRUE_VALUES:
- return True
- if lowered in FALSE_VALUES:
- return False
- raise ValueError("value must be a boolean (true/false)")
- def _convert_int(self, value: Any) -> int | None:
- if isinstance(value, bool):
- raise ValueError("value must be an integer")
- if isinstance(value, int):
- return value
- if isinstance(value, str) and value.strip() == "":
- return None
- try:
- return int(value)
- except (TypeError, ValueError) as exc:
- raise ValueError("value must be an integer") from exc
- def _convert_float(self, value: Any) -> float | None:
- if isinstance(value, bool):
- raise ValueError("value must be a float")
- if isinstance(value, (int, float)):
- return float(value)
- if isinstance(value, str) and value.strip() == "":
- return None
- try:
- return float(value)
- except (TypeError, ValueError) as exc:
- raise ValueError("value must be a float") from exc
- def _convert_enum(self, value: Any) -> str | None:
- if value == "":
- return None
- val = str(value)
- if self.options and val not in self.options:
- raise ValueError(f"value must be one of: {', '.join(self.options)}")
- return val
- def _convert_url(self, value: Any) -> str | None:
- val = str(value).strip()
- if not val:
- return None
- parsed = urlparse(val)
- if not (parsed.scheme and parsed.netloc):
- raise ValueError("value must be a valid URL (include scheme and host)")
- return val
- def _convert_email(self, value: Any) -> str | None:
- val = str(value).strip()
- if not val:
- return None
- try:
- validated = validate_email(val, check_deliverability=False)
- return validated.normalized
- except EmailNotValidError as exc:
- raise ValueError(f"value must be a valid email address: {exc}") from exc
- def _validate_slider_value(self, value: int) -> None:
- if not self.config.slider:
- return
- min_value = self.config.min
- max_value = self.config.max
- if min_value is None or max_value is None:
- return
- if value < min_value:
- raise ValueError(f"value must be at least {min_value}")
- if value > max_value:
- raise ValueError(f"value must be at most {max_value}")
- step = self.config.step if self.config.step is not None else 1
- if step > 0 and (value - min_value) % step != 0:
- raise ValueError(f"value must align with step {step} starting at {min_value}")
- def to_dict(self) -> dict[str, Any]:
- result: dict[str, Any] = {}
- if self.type:
- result["type"] = self.type
- if self.value is not None:
- result["default"] = self.value
- for field in ("description", "prompt", "extra", "origin"):
- if value := getattr(self, field):
- result[field] = value
- if self.required:
- result["required"] = True
- config_dict = self._serialize_config()
- if config_dict:
- result["config"] = config_dict
- if self.needs:
- result["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs
- return result
- def _serialize_config(self) -> dict[str, Any]:
- if not self.config or self.config.is_empty():
- return {}
- config_dict: dict[str, Any] = {}
- value_fields = {
- "placeholder": self.config.placeholder,
- "unit": self.config.unit,
- "options": self.config.options,
- }
- for field_name, field_value in value_fields.items():
- if field_value:
- config_dict[field_name] = field_value
- if self.config.textarea:
- config_dict["textarea"] = True
- if self.config.slider:
- config_dict["slider"] = True
- for field_name in ("min", "max", "step"):
- field_value = getattr(self.config, field_name)
- if field_value is not None:
- config_dict[field_name] = field_value
- autogenerated_dict = self._serialize_autogenerated_config()
- if autogenerated_dict is not None:
- config_dict["autogenerated"] = autogenerated_dict
- return config_dict
- def _serialize_autogenerated_config(self) -> bool | dict[str, Any] | None:
- autogenerated = self.config.autogenerated
- if not autogenerated:
- return None
- if self._is_default_character_autogenerated(autogenerated):
- return True
- autogenerated_dict = {"kind": autogenerated.kind}
- for field_name in ("length", "characters", "bytes"):
- field_value = getattr(autogenerated, field_name)
- if field_value is not None:
- autogenerated_dict[field_name] = field_value
- return autogenerated_dict
- @staticmethod
- def _is_default_character_autogenerated(autogenerated: SecretAutogeneratedConfig) -> bool:
- return (
- autogenerated.kind == SECRET_AUTOGENERATED_KIND_CHARACTERS
- and autogenerated.length is None
- and autogenerated.characters is None
- and autogenerated.bytes is None
- )
- def get_display_value(self, mask_secret: bool = True, max_length: int = 30, show_none: bool = True) -> str:
- if self.value is None or self.value == "":
- if self.autogenerated:
- return "[dim](*auto)[/dim]" if show_none else ""
- return "[dim](none)[/dim]" if show_none else ""
- if self.is_secret() and mask_secret:
- return "********"
- display = str(self.value)
- if max_length > 0 and len(display) > max_length:
- return display[: max_length - 3] + "..."
- return display
- def get_normalized_default(self) -> Any:
- typed = self._coerce_default_value()
- if self.autogenerated and not typed:
- return "*auto"
- normalizers = {
- "enum": self._normalize_enum_default,
- "bool": self._normalize_bool_default,
- "int": lambda value: self._normalize_numeric_default(value, int),
- "float": lambda value: self._normalize_numeric_default(value, float),
- }
- normalizer = normalizers.get(self.type, self._normalize_string_default)
- return normalizer(typed)
- def _coerce_default_value(self) -> Any:
- try:
- return self.convert(self.value)
- except Exception:
- return self.value
- def _normalize_enum_default(self, typed: Any) -> Any:
- if not self.options:
- return typed
- if typed is None or str(typed) not in self.options:
- return self.options[0]
- return str(typed)
- @staticmethod
- def _normalize_bool_default(typed: Any) -> bool | None:
- if isinstance(typed, bool):
- return typed
- if typed is None:
- return None
- return bool(typed)
- @staticmethod
- def _normalize_numeric_default(typed: Any, caster) -> Any:
- try:
- return caster(typed) if typed not in (None, "") else None
- except Exception:
- return None
- @staticmethod
- def _normalize_string_default(typed: Any) -> str | None:
- return None if typed is None else str(typed)
- def get_prompt_text(self) -> str:
- prompt_text = self.prompt or self.description or self.name
- if self.value is not None and self.type in ["email", "url"]:
- prompt_text += f" ({self.type})"
- return prompt_text
- def get_validation_hint(self) -> str | None:
- hints = []
- if self.type == "enum" and self.options:
- hints.append(f"Options: {', '.join(self.options)}")
- if self.type == "int" and self.config.slider and self.config.min is not None and self.config.max is not None:
- slider_hint = f"Range: {self.config.min}..{self.config.max}"
- step = self.config.step if self.config.step is not None else 1
- if step != 1:
- slider_hint += f", step {step}"
- if self.config.unit:
- slider_hint += f" {self.config.unit}"
- hints.append(slider_hint)
- elif self.type == "int" and self.config.unit:
- hints.append(f"Unit: {self.config.unit}")
- if self.autogenerated:
- if self.autogenerated_base64:
- bytes_value = (
- self.autogenerated_config.bytes_or_default()
- if self.autogenerated_config
- else DEFAULT_AUTOGENERATED_BYTES
- )
- hints.append(f"Auto-generated base64 secret ({bytes_value} bytes) if empty")
- else:
- length = (
- self.autogenerated_config.length_or_default()
- if self.autogenerated_config
- else DEFAULT_AUTOGENERATED_LENGTH
- )
- hints.append(f"Auto-generated secret (length {length}) if empty")
- if self.extra:
- hints.append(self.extra)
- return " — ".join(hints) if hints else None
- def is_required(self) -> bool:
- return self.required and not self.autogenerated
- def get_parent(self) -> VariableSection | None:
- return self.parent_section
- def clone(self, update: dict[str, Any] | None = None) -> Variable:
- data = {
- "name": self.name,
- "type": self.type,
- "value": self.value,
- "description": self.description,
- "prompt": self.prompt,
- "origin": self.origin,
- "extra": self.extra,
- "required": self.required,
- "original_value": self.original_value,
- "needs": self.needs.copy() if self.needs else None,
- "parent_section": self.parent_section,
- "config": self.config.clone() if self.config else None,
- }
- if update:
- data.update(update)
- cloned = Variable(data)
- cloned._explicit_fields = self._explicit_fields.copy()
- if update:
- cloned._explicit_fields.update(update.keys())
- return cloned
|