| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431 |
- 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
|