variable.py 17 KB

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