variable.py 14 KB

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