from __future__ import annotations from typing import Any, Dict, List, Optional, Set from urllib.parse import urlparse import logging import re logger = logging.getLogger(__name__) TRUE_VALUES = {"true", "1", "yes", "on"} FALSE_VALUES = {"false", "0", "no", "off"} EMAIL_REGEX = re.compile(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") class Variable: """Represents a single templating variable with lightweight validation.""" def __init__(self, data: dict[str, Any]) -> None: """Initialize Variable from a dictionary containing variable specification. Args: data: Dictionary containing variable specification with required 'name' key and optional keys: description, type, options, prompt, value, default, section, origin Raises: ValueError: If data is not a dict, missing 'name' key, or has invalid default value """ # Validate input 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") # Track which fields were explicitly provided in source data self._explicit_fields: Set[str] = set(data.keys()) # Initialize fields self.name: str = data["name"] self.description: Optional[str] = data.get("description") or data.get("display", "") self.type: str = data.get("type", "str") self.options: Optional[List[Any]] = data.get("options", []) self.prompt: Optional[str] = data.get("prompt") self.value: Any = data.get("value") if data.get("value") is not None else data.get("default") self.origin: Optional[str] = data.get("origin") self.sensitive: bool = data.get("sensitive", False) # Optional extra explanation used by interactive prompts self.extra: Optional[str] = data.get("extra") # Flag indicating this variable should be auto-generated when empty self.autogenerated: bool = data.get("autogenerated", False) # Original value before config override (used for display) self.original_value: Optional[Any] = data.get("original_value") # Validate and convert the default/initial value if present if self.value is not None: try: self.value = self.convert(self.value) except ValueError as exc: raise ValueError(f"Invalid default for variable '{self.name}': {exc}") def convert(self, value: Any) -> Any: """Validate and convert a raw value based on the variable type. This method performs type conversion but does NOT check if the value is required. Use validate_and_convert() for full validation including required field checks. """ if value is None: return None # Treat empty strings as None to avoid storing "" for missing values. if isinstance(value, str) and value.strip() == "": return None # Type conversion mapping for cleaner code 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) # Default to string conversion return str(value) def validate_and_convert(self, value: Any, check_required: bool = True) -> Any: """Validate and convert a value with comprehensive checks. This method combines type conversion with validation logic including required field checks. It's the recommended method for user input validation. Args: value: The raw value to validate and convert check_required: If True, raises ValueError for required fields with empty values Returns: The converted and validated value Raises: ValueError: If validation fails (invalid format, required field empty, etc.) Examples: # Basic validation var.validate_and_convert("example@email.com") # Returns validated email # Required field validation var.validate_and_convert("", check_required=True) # Raises ValueError if required # Autogenerated variables - allow empty values var.validate_and_convert("", check_required=False) # Returns None for autogeneration """ # First, convert the value using standard type conversion converted = self.convert(value) # Special handling for autogenerated variables # Allow empty values as they will be auto-generated later if self.autogenerated and (converted is None or (isinstance(converted, str) and (converted == "" or converted == "*auto"))): return None # Signal that auto-generation should happen # Check if this is a required field and the value is empty if check_required and self.is_required(): if converted is None or (isinstance(converted, str) and converted == ""): raise ValueError("This field is required and cannot be empty") return converted def _convert_bool(self, value: Any) -> bool: """Convert value to boolean.""" 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) -> Optional[int]: """Convert value to 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) -> Optional[float]: """Convert value to float.""" if isinstance(value, float): return 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) -> Optional[str]: 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: 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: val = str(value).strip() if not val: return None if not EMAIL_REGEX.fullmatch(val): raise ValueError("value must be a valid email address") return val def to_dict(self) -> Dict[str, Any]: """Serialize Variable to a dictionary for storage.""" result = {} # Always include type if self.type: result['type'] = self.type # Include value/default if not None if self.value is not None: result['default'] = self.value # Include string fields if truthy for field in ('description', 'prompt', 'extra', 'origin'): if value := getattr(self, field): result[field] = value # Include boolean/list fields if truthy (but empty list is OK for options) if self.sensitive: result['sensitive'] = True if self.autogenerated: result['autogenerated'] = True if self.options is not None: # Allow empty list result['options'] = self.options return result def get_display_value(self, mask_sensitive: bool = True, max_length: int = 30, show_none: bool = True) -> str: """Get formatted display value with optional masking and truncation. Args: mask_sensitive: If True, mask sensitive values with asterisks max_length: Maximum length before truncation (0 = no limit) show_none: If True, display "(none)" for None values instead of empty string Returns: Formatted string representation of the value """ if self.value is None or self.value == "": # Show (*auto) for autogenerated variables instead of (none) if self.autogenerated: return "[dim](*auto)[/dim]" if show_none else "" return "[dim](none)[/dim]" if show_none else "" # Mask sensitive values if self.sensitive and mask_sensitive: return "********" # Convert to string display = str(self.value) # Truncate if needed if max_length > 0 and len(display) > max_length: return display[:max_length - 3] + "..." return display def get_normalized_default(self) -> Any: """Get normalized default value suitable for prompts and display.""" try: typed = self.convert(self.value) except Exception: typed = self.value # Autogenerated: return display hint if self.autogenerated and not typed: return "*auto" # Type-specific handlers if self.type == "enum": if not self.options: return typed return self.options[0] if typed is None or str(typed) not in self.options else str(typed) if self.type == "bool": return typed if isinstance(typed, bool) else (None if typed is None else bool(typed)) if self.type == "int": try: return int(typed) if typed not in (None, "") else None except Exception: return None # Default: return string or None return None if typed is None else str(typed) def get_prompt_text(self) -> str: """Get formatted prompt text for interactive input. Returns: Prompt text with optional type hints and descriptions """ prompt_text = self.prompt or self.description or self.name # Add type hint for semantic types if there's a default 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) -> Optional[str]: """Get validation hint for prompts (e.g., enum options). Returns: Formatted hint string or None if no hint needed """ hints = [] # Add enum options if self.type == "enum" and self.options: hints.append(f"Options: {', '.join(self.options)}") # Add extra help text if self.extra: hints.append(self.extra) return " — ".join(hints) if hints else None def is_required(self) -> bool: """Check if this variable requires a value (cannot be empty/None). A variable is considered required if: - It doesn't have a default value (value is None) - It's not marked as autogenerated (which can be empty and generated later) - It's not a boolean type (booleans default to False if not set) Returns: True if the variable must have a non-empty value, False otherwise """ # Autogenerated variables can be empty (will be generated later) if self.autogenerated: return False # Boolean variables always have a value (True or False) if self.type == "bool": return False # Variables with a default value are not required if self.value is not None: return False # No default value and not autogenerated = required return True def clone(self, update: Optional[Dict[str, Any]] = None) -> 'Variable': """Create a deep copy of the variable with optional field updates. This is more efficient than converting to dict and back when copying variables. Args: update: Optional dictionary of field updates to apply to the clone Returns: New Variable instance with copied data Example: var2 = var1.clone(update={'origin': 'template'}) """ data = { 'name': self.name, 'type': self.type, 'value': self.value, 'description': self.description, 'prompt': self.prompt, 'options': self.options.copy() if self.options else None, 'origin': self.origin, 'sensitive': self.sensitive, 'extra': self.extra, 'autogenerated': self.autogenerated, 'original_value': self.original_value, } # Apply updates if provided if update: data.update(update) # Create new variable cloned = Variable(data) # Preserve explicit fields from original, and add any update keys cloned._explicit_fields = self._explicit_fields.copy() if update: cloned._explicit_fields.update(update.keys()) return cloned