from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from email_validator import EmailNotValidError, validate_email if TYPE_CHECKING: from cli.core.template.variable_section import VariableSection logger = logging.getLogger(__name__) # Constants DEFAULT_AUTOGENERATED_LENGTH = 32 TRUE_VALUES = {"true", "1", "yes", "on"} FALSE_VALUES = {"false", "0", "no", "off"} 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"] # Reference to parent section (set by VariableCollection) self.parent_section: VariableSection | None = data.get("parent_section") self.description: str | None = data.get("description") or data.get("display", "") self.type: str = data.get("type", "str") self.options: list[Any] | None = data.get("options", []) self.prompt: str | None = data.get("prompt") if "value" in data: self.value: Any = data.get("value") elif "default" in data: self.value: Any = data.get("default") else: # RULE: If bool variables don't have any default value or value at all, # automatically set them to false self.value: Any = False if self.type == "bool" else None self.origin: str | None = data.get("origin") self.sensitive: bool = data.get("sensitive", False) # Optional extra explanation used by interactive prompts self.extra: str | None = data.get("extra") # Flag indicating this variable should be auto-generated when empty self.autogenerated: bool = data.get("autogenerated", False) # Length of auto-generated value self.autogenerated_length: int = data.get("autogenerated_length", DEFAULT_AUTOGENERATED_LENGTH) # Flag indicating if autogenerated value should be base64 encoded self.autogenerated_base64: bool = data.get("autogenerated_base64", False) # Flag indicating this variable is required (must have a value) self.required: bool = data.get("required", False) # Original value before config override (used for display) self.original_value: Any | None = data.get("original_value") # Variable dependencies - can be string or list of strings in format "var_name=value" # Supports semicolon-separated multiple conditions: "var1=value1;var2=value2,value3" needs_value = data.get("needs") if needs_value: if isinstance(needs_value, str): # Split by semicolon to support multiple AND conditions in a single string # Example: "traefik_enabled=true;network_mode=bridge,macvlan" self.needs: list[str] = [need.strip() for need in needs_value.split(";") if need.strip()] elif isinstance(needs_value, list): self.needs: list[str] = needs_value else: raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list") else: self.needs: list[str] = [] # 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}") from 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 in {"", "*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() and (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) -> int | None: """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) -> float | None: """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) -> str | None: 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 try: # Validate email using RFC 5321/5322 compliant parser validated = validate_email(val, check_deliverability=False) return validated.normalized except EmailNotValidError as exc: raise ValueError(f"value must be a valid email address: {exc}") from exc 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 # Only include length if not default if self.autogenerated_length != DEFAULT_AUTOGENERATED_LENGTH: result["autogenerated_length"] = self.autogenerated_length # Include base64 flag if enabled if self.autogenerated_base64: result["autogenerated_base64"] = True if self.required: result["required"] = True if self.options is not None: # Allow empty list result["options"] = self.options # Store dependencies (single value if only one, list otherwise) if self.needs: result["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs 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": return ( typed if not self.options else (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) -> str | None: """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 ONLY if it has an explicit 'required: true' flag. All other variables are optional by default. Returns: True if the variable must have a non-empty value, False otherwise """ # Only explicitly marked required variables are required # Autogenerated variables can still be empty (will be generated later) return self.required and not self.autogenerated def get_parent(self) -> VariableSection | None: """Get the parent VariableSection that contains this variable. Returns: The parent VariableSection if set, None otherwise """ return self.parent_section def clone(self, update: dict[str, Any] | None = 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, "autogenerated_length": self.autogenerated_length, "autogenerated_base64": self.autogenerated_base64, "required": self.required, "original_value": self.original_value, "needs": self.needs.copy() if self.needs else None, "parent_section": self.parent_section, } # 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