variable.py 16 KB

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