variable.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. from __future__ import annotations
  2. import logging
  3. from typing import TYPE_CHECKING, Any
  4. from urllib.parse import urlparse
  5. from email_validator import EmailNotValidError, validate_email
  6. if TYPE_CHECKING:
  7. from cli.core.template.variable_section import VariableSection
  8. logger = logging.getLogger(__name__)
  9. # Constants
  10. DEFAULT_AUTOGENERATED_LENGTH = 32
  11. TRUE_VALUES = {"true", "1", "yes", "on"}
  12. FALSE_VALUES = {"false", "0", "no", "off"}
  13. class Variable:
  14. """Represents a single templating variable with lightweight validation."""
  15. def __init__(self, data: dict[str, Any]) -> None:
  16. """Initialize Variable from a dictionary containing variable specification.
  17. Args:
  18. data: Dictionary containing variable specification with required
  19. 'name' key and optional keys: description, type, options,
  20. prompt, value, default, section, origin
  21. Raises:
  22. ValueError: If data is not a dict, missing 'name' key, or has invalid default value
  23. """
  24. # Validate input
  25. if not isinstance(data, dict):
  26. raise ValueError("Variable data must be a dictionary")
  27. if "name" not in data:
  28. raise ValueError("Variable data must contain 'name' key")
  29. # Track which fields were explicitly provided in source data
  30. self._explicit_fields: set[str] = set(data.keys())
  31. # Initialize fields
  32. self.name: str = data["name"]
  33. # Reference to parent section (set by VariableCollection)
  34. self.parent_section: VariableSection | None = data.get("parent_section")
  35. self.description: str | None = data.get("description") or data.get("display", "")
  36. self.type: str = data.get("type", "str")
  37. self.options: list[Any] | None = data.get("options", [])
  38. self.prompt: str | None = data.get("prompt")
  39. if "value" in data:
  40. self.value: Any = data.get("value")
  41. elif "default" in data:
  42. self.value: Any = data.get("default")
  43. else:
  44. self.value: Any = None
  45. self.origin: str | None = data.get("origin")
  46. self.sensitive: bool = data.get("sensitive", False)
  47. # Optional extra explanation used by interactive prompts
  48. self.extra: str | None = data.get("extra")
  49. # Flag indicating this variable should be auto-generated when empty
  50. self.autogenerated: bool = data.get("autogenerated", False)
  51. # Length of auto-generated value
  52. self.autogenerated_length: int = data.get("autogenerated_length", DEFAULT_AUTOGENERATED_LENGTH)
  53. # Flag indicating this variable is required (must have a value)
  54. self.required: bool = data.get("required", False)
  55. # Original value before config override (used for display)
  56. self.original_value: Any | None = data.get("original_value")
  57. # Variable dependencies - can be string or list of strings in format "var_name=value"
  58. # Supports semicolon-separated multiple conditions: "var1=value1;var2=value2,value3"
  59. needs_value = data.get("needs")
  60. if needs_value:
  61. if isinstance(needs_value, str):
  62. # Split by semicolon to support multiple AND conditions in a single string
  63. # Example: "traefik_enabled=true;network_mode=bridge,macvlan"
  64. self.needs: list[str] = [need.strip() for need in needs_value.split(";") if need.strip()]
  65. elif isinstance(needs_value, list):
  66. self.needs: list[str] = needs_value
  67. else:
  68. raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list")
  69. else:
  70. self.needs: list[str] = []
  71. # Validate and convert the default/initial value if present
  72. if self.value is not None:
  73. try:
  74. self.value = self.convert(self.value)
  75. except ValueError as exc:
  76. raise ValueError(f"Invalid default for variable '{self.name}': {exc}") from exc
  77. def convert(self, value: Any) -> Any:
  78. """Validate and convert a raw value based on the variable type.
  79. This method performs type conversion but does NOT check if the value
  80. is required. Use validate_and_convert() for full validation including
  81. required field checks.
  82. """
  83. if value is None:
  84. return None
  85. # Treat empty strings as None to avoid storing "" for missing values.
  86. if isinstance(value, str) and value.strip() == "":
  87. return None
  88. # Type conversion mapping for cleaner code
  89. converters = {
  90. "bool": self._convert_bool,
  91. "int": self._convert_int,
  92. "float": self._convert_float,
  93. "enum": self._convert_enum,
  94. "url": self._convert_url,
  95. "email": self._convert_email,
  96. }
  97. converter = converters.get(self.type)
  98. if converter:
  99. return converter(value)
  100. # Default to string conversion
  101. return str(value)
  102. def validate_and_convert(self, value: Any, check_required: bool = True) -> Any:
  103. """Validate and convert a value with comprehensive checks.
  104. This method combines type conversion with validation logic including
  105. required field checks. It's the recommended method for user input validation.
  106. Args:
  107. value: The raw value to validate and convert
  108. check_required: If True, raises ValueError for required fields with empty values
  109. Returns:
  110. The converted and validated value
  111. Raises:
  112. ValueError: If validation fails (invalid format, required field empty, etc.)
  113. Examples:
  114. # Basic validation
  115. var.validate_and_convert("example@email.com") # Returns validated email
  116. # Required field validation
  117. var.validate_and_convert("", check_required=True) # Raises ValueError if required
  118. # Autogenerated variables - allow empty values
  119. var.validate_and_convert("", check_required=False) # Returns None for autogeneration
  120. """
  121. # First, convert the value using standard type conversion
  122. converted = self.convert(value)
  123. # Special handling for autogenerated variables
  124. # Allow empty values as they will be auto-generated later
  125. if self.autogenerated and (converted is None or (isinstance(converted, str) and (converted in {"", "*auto"}))):
  126. return None # Signal that auto-generation should happen
  127. # Check if this is a required field and the value is empty
  128. if (
  129. check_required
  130. and self.is_required()
  131. and (converted is None or (isinstance(converted, str) and converted == ""))
  132. ):
  133. raise ValueError("This field is required and cannot be empty")
  134. return converted
  135. def _convert_bool(self, value: Any) -> bool:
  136. """Convert value to boolean."""
  137. if isinstance(value, bool):
  138. return value
  139. if isinstance(value, str):
  140. lowered = value.strip().lower()
  141. if lowered in TRUE_VALUES:
  142. return True
  143. if lowered in FALSE_VALUES:
  144. return False
  145. raise ValueError("value must be a boolean (true/false)")
  146. def _convert_int(self, value: Any) -> int | None:
  147. """Convert value to integer."""
  148. if isinstance(value, int):
  149. return value
  150. if isinstance(value, str) and value.strip() == "":
  151. return None
  152. try:
  153. return int(value)
  154. except (TypeError, ValueError) as exc:
  155. raise ValueError("value must be an integer") from exc
  156. def _convert_float(self, value: Any) -> float | None:
  157. """Convert value to float."""
  158. if isinstance(value, float):
  159. return value
  160. if isinstance(value, str) and value.strip() == "":
  161. return None
  162. try:
  163. return float(value)
  164. except (TypeError, ValueError) as exc:
  165. raise ValueError("value must be a float") from exc
  166. def _convert_enum(self, value: Any) -> str | None:
  167. if value == "":
  168. return None
  169. val = str(value)
  170. if self.options and val not in self.options:
  171. raise ValueError(f"value must be one of: {', '.join(self.options)}")
  172. return val
  173. def _convert_url(self, value: Any) -> str:
  174. val = str(value).strip()
  175. if not val:
  176. return None
  177. parsed = urlparse(val)
  178. if not (parsed.scheme and parsed.netloc):
  179. raise ValueError("value must be a valid URL (include scheme and host)")
  180. return val
  181. def _convert_email(self, value: Any) -> str:
  182. val = str(value).strip()
  183. if not val:
  184. return None
  185. try:
  186. # Validate email using RFC 5321/5322 compliant parser
  187. validated = validate_email(val, check_deliverability=False)
  188. return validated.normalized
  189. except EmailNotValidError as exc:
  190. raise ValueError(f"value must be a valid email address: {exc}") from exc
  191. def to_dict(self) -> dict[str, Any]:
  192. """Serialize Variable to a dictionary for storage."""
  193. result = {}
  194. # Always include type
  195. if self.type:
  196. result["type"] = self.type
  197. # Include value/default if not None
  198. if self.value is not None:
  199. result["default"] = self.value
  200. # Include string fields if truthy
  201. for field in ("description", "prompt", "extra", "origin"):
  202. if value := getattr(self, field):
  203. result[field] = value
  204. # Include boolean/list fields if truthy (but empty list is OK for options)
  205. if self.sensitive:
  206. result["sensitive"] = True
  207. if self.autogenerated:
  208. result["autogenerated"] = True
  209. # Only include length if not default
  210. if self.autogenerated_length != DEFAULT_AUTOGENERATED_LENGTH:
  211. result["autogenerated_length"] = self.autogenerated_length
  212. if self.required:
  213. result["required"] = True
  214. if self.options is not None: # Allow empty list
  215. result["options"] = self.options
  216. # Store dependencies (single value if only one, list otherwise)
  217. if self.needs:
  218. result["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs
  219. return result
  220. def get_display_value(self, mask_sensitive: bool = True, max_length: int = 30, show_none: bool = True) -> str:
  221. """Get formatted display value with optional masking and truncation.
  222. Args:
  223. mask_sensitive: If True, mask sensitive values with asterisks
  224. max_length: Maximum length before truncation (0 = no limit)
  225. show_none: If True, display "(none)" for None values instead of empty string
  226. Returns:
  227. Formatted string representation of the value
  228. """
  229. if self.value is None or self.value == "":
  230. # Show (*auto) for autogenerated variables instead of (none)
  231. if self.autogenerated:
  232. return "[dim](*auto)[/dim]" if show_none else ""
  233. return "[dim](none)[/dim]" if show_none else ""
  234. # Mask sensitive values
  235. if self.sensitive and mask_sensitive:
  236. return "********"
  237. # Convert to string
  238. display = str(self.value)
  239. # Truncate if needed
  240. if max_length > 0 and len(display) > max_length:
  241. return display[: max_length - 3] + "..."
  242. return display
  243. def get_normalized_default(self) -> Any:
  244. """Get normalized default value suitable for prompts and display."""
  245. try:
  246. typed = self.convert(self.value)
  247. except Exception:
  248. typed = self.value
  249. # Autogenerated: return display hint
  250. if self.autogenerated and not typed:
  251. return "*auto"
  252. # Type-specific handlers
  253. if self.type == "enum":
  254. return (
  255. typed
  256. if not self.options
  257. else (self.options[0] if typed is None or str(typed) not in self.options else str(typed))
  258. )
  259. if self.type == "bool":
  260. return typed if isinstance(typed, bool) else (None if typed is None else bool(typed))
  261. if self.type == "int":
  262. try:
  263. return int(typed) if typed not in (None, "") else None
  264. except Exception:
  265. return None
  266. # Default: return string or None
  267. return None if typed is None else str(typed)
  268. def get_prompt_text(self) -> str:
  269. """Get formatted prompt text for interactive input.
  270. Returns:
  271. Prompt text with optional type hints and descriptions
  272. """
  273. prompt_text = self.prompt or self.description or self.name
  274. # Add type hint for semantic types if there's a default
  275. if self.value is not None and self.type in ["email", "url"]:
  276. prompt_text += f" ({self.type})"
  277. return prompt_text
  278. def get_validation_hint(self) -> str | None:
  279. """Get validation hint for prompts (e.g., enum options).
  280. Returns:
  281. Formatted hint string or None if no hint needed
  282. """
  283. hints = []
  284. # Add enum options
  285. if self.type == "enum" and self.options:
  286. hints.append(f"Options: {', '.join(self.options)}")
  287. # Add extra help text
  288. if self.extra:
  289. hints.append(self.extra)
  290. return " — ".join(hints) if hints else None
  291. def is_required(self) -> bool:
  292. """Check if this variable requires a value (cannot be empty/None).
  293. A variable is considered required ONLY if it has an explicit 'required: true' flag.
  294. All other variables are optional by default.
  295. Returns:
  296. True if the variable must have a non-empty value, False otherwise
  297. """
  298. # Only explicitly marked required variables are required
  299. # Autogenerated variables can still be empty (will be generated later)
  300. return self.required and not self.autogenerated
  301. def get_parent(self) -> VariableSection | None:
  302. """Get the parent VariableSection that contains this variable.
  303. Returns:
  304. The parent VariableSection if set, None otherwise
  305. """
  306. return self.parent_section
  307. def clone(self, update: dict[str, Any] | None = None) -> Variable:
  308. """Create a deep copy of the variable with optional field updates.
  309. This is more efficient than converting to dict and back when copying variables.
  310. Args:
  311. update: Optional dictionary of field updates to apply to the clone
  312. Returns:
  313. New Variable instance with copied data
  314. Example:
  315. var2 = var1.clone(update={'origin': 'template'})
  316. """
  317. data = {
  318. "name": self.name,
  319. "type": self.type,
  320. "value": self.value,
  321. "description": self.description,
  322. "prompt": self.prompt,
  323. "options": self.options.copy() if self.options else None,
  324. "origin": self.origin,
  325. "sensitive": self.sensitive,
  326. "extra": self.extra,
  327. "autogenerated": self.autogenerated,
  328. "autogenerated_length": self.autogenerated_length,
  329. "required": self.required,
  330. "original_value": self.original_value,
  331. "needs": self.needs.copy() if self.needs else None,
  332. "parent_section": self.parent_section,
  333. }
  334. # Apply updates if provided
  335. if update:
  336. data.update(update)
  337. # Create new variable
  338. cloned = Variable(data)
  339. # Preserve explicit fields from original, and add any update keys
  340. cloned._explicit_fields = self._explicit_fields.copy()
  341. if update:
  342. cloned._explicit_fields.update(update.keys())
  343. return cloned