variable.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  1. from __future__ import annotations
  2. from dataclasses import dataclass
  3. from typing import TYPE_CHECKING, Any
  4. from urllib.parse import urlparse
  5. from email_validator import EmailNotValidError, validate_email
  6. if TYPE_CHECKING:
  7. from cli.core.template.variable_section import VariableSection
  8. DEFAULT_AUTOGENERATED_LENGTH = 32
  9. DEFAULT_AUTOGENERATED_BYTES = 32
  10. TRUE_VALUES = {"true", "1", "yes", "on"}
  11. FALSE_VALUES = {"false", "0", "no", "off"}
  12. SECRET_TYPE = "secret"
  13. SECRET_AUTOGENERATED_KIND_CHARACTERS = "characters"
  14. SECRET_AUTOGENERATED_KIND_BASE64 = "base64"
  15. @dataclass
  16. class SecretAutogeneratedConfig:
  17. """Structured autogeneration settings for secret variables."""
  18. kind: str = SECRET_AUTOGENERATED_KIND_CHARACTERS
  19. length: int | None = None
  20. characters: list[str] | None = None
  21. bytes: int | None = None
  22. def clone(self) -> SecretAutogeneratedConfig:
  23. return SecretAutogeneratedConfig(
  24. kind=self.kind,
  25. length=self.length,
  26. characters=self.characters.copy() if self.characters else None,
  27. bytes=self.bytes,
  28. )
  29. def length_or_default(self) -> int:
  30. return self.length if self.length is not None else DEFAULT_AUTOGENERATED_LENGTH
  31. def bytes_or_default(self) -> int:
  32. return self.bytes if self.bytes is not None else DEFAULT_AUTOGENERATED_BYTES
  33. @dataclass
  34. class VariableConfig:
  35. """Type-specific variable configuration."""
  36. placeholder: str | None = None
  37. textarea: bool = False
  38. unit: str | None = None
  39. options: list[str] | None = None
  40. slider: bool = False
  41. min: int | None = None
  42. max: int | None = None
  43. step: int | None = None
  44. autogenerated: SecretAutogeneratedConfig | None = None
  45. def clone(self) -> VariableConfig:
  46. return VariableConfig(
  47. placeholder=self.placeholder,
  48. textarea=self.textarea,
  49. unit=self.unit,
  50. options=self.options.copy() if self.options else None,
  51. slider=self.slider,
  52. min=self.min,
  53. max=self.max,
  54. step=self.step,
  55. autogenerated=self.autogenerated.clone() if self.autogenerated else None,
  56. )
  57. def is_empty(self) -> bool:
  58. return (
  59. not self.placeholder
  60. and not self.textarea
  61. and not self.unit
  62. and not self.options
  63. and not self.slider
  64. and self.min is None
  65. and self.max is None
  66. and self.step is None
  67. and self.autogenerated is None
  68. )
  69. class Variable:
  70. """Represents a single templating variable with lightweight validation."""
  71. def __init__(self, data: dict[str, Any]) -> None:
  72. self._validate_input_data(data)
  73. self._explicit_fields: set[str] = set(data.keys())
  74. self.name: str = data["name"]
  75. self.parent_section: VariableSection | None = data.get("parent_section")
  76. self.description: str | None = data.get("description") or data.get("display", "")
  77. self.type: str = data.get("type", "str")
  78. self.prompt: str | None = data.get("prompt")
  79. self.value: Any = self._resolve_initial_value(data)
  80. self.origin: str | None = data.get("origin")
  81. self.extra: str | None = data.get("extra")
  82. self.required: bool = data.get("required", False)
  83. self.original_value: Any | None = data.get("original_value")
  84. self.config = self._normalize_config(self.name, self.type, data.get("config"))
  85. self._apply_config_state()
  86. self._validate_secret_defaults(data)
  87. self.needs = self._parse_needs(data.get("needs"))
  88. self._validate_initial_value()
  89. @staticmethod
  90. def _validate_input_data(data: dict[str, Any]) -> None:
  91. if not isinstance(data, dict):
  92. raise ValueError("Variable data must be a dictionary")
  93. if "name" not in data:
  94. raise ValueError("Variable data must contain 'name' key")
  95. def _resolve_initial_value(self, data: dict[str, Any]) -> Any:
  96. if "value" in data:
  97. return data.get("value")
  98. if "default" in data:
  99. return data.get("default")
  100. return False if self.type == "bool" else None
  101. def _apply_config_state(self) -> None:
  102. self.options: list[str] | None = self.config.options.copy() if self.config.options else None
  103. self.autogenerated_config: SecretAutogeneratedConfig | None = (
  104. self.config.autogenerated.clone() if self.config.autogenerated else None
  105. )
  106. self.autogenerated: bool = self.autogenerated_config is not None
  107. self.autogenerated_length: int = (
  108. self.autogenerated_config.length_or_default() if self.autogenerated_config else DEFAULT_AUTOGENERATED_LENGTH
  109. )
  110. self.autogenerated_base64: bool = bool(
  111. self.autogenerated_config and self.autogenerated_config.kind == SECRET_AUTOGENERATED_KIND_BASE64
  112. )
  113. def _validate_secret_defaults(self, data: dict[str, Any]) -> None:
  114. if self.type == SECRET_TYPE and self.autogenerated and "default" in data:
  115. raise ValueError(
  116. f"Invalid default for variable '{self.name}': autogenerated secrets cannot define defaults"
  117. )
  118. def _parse_needs(self, needs_value: Any) -> list[str]:
  119. if not needs_value:
  120. return []
  121. if isinstance(needs_value, str):
  122. return [need.strip() for need in needs_value.split(";") if need.strip()]
  123. if isinstance(needs_value, list):
  124. return needs_value
  125. raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list")
  126. def _validate_initial_value(self) -> None:
  127. if self.value is None:
  128. return
  129. try:
  130. self.value = self.convert(self.value)
  131. if self.type == "int" and self.value is not None and self.config.slider:
  132. self._validate_slider_value(self.value)
  133. except ValueError as exc:
  134. raise ValueError(f"Invalid default for variable '{self.name}': {exc}") from exc
  135. @staticmethod
  136. def _normalize_str_list(values: list[Any] | None) -> list[str] | None:
  137. if not values:
  138. return None
  139. normalized: list[str] = []
  140. seen: set[str] = set()
  141. for value in values:
  142. item = str(value).strip()
  143. if not item or item in seen:
  144. continue
  145. seen.add(item)
  146. normalized.append(item)
  147. return normalized or None
  148. @classmethod
  149. def _parse_secret_autogenerated(
  150. cls,
  151. variable_name: str,
  152. autogenerated_input: Any,
  153. legacy_length: Any = None,
  154. legacy_base64: bool = False,
  155. ) -> SecretAutogeneratedConfig | None:
  156. if autogenerated_input in (None, False):
  157. return cls._legacy_secret_autogenerated(legacy_length, legacy_base64)
  158. if autogenerated_input is True:
  159. return cls._boolean_secret_autogenerated(variable_name, legacy_length, legacy_base64)
  160. if not isinstance(autogenerated_input, dict):
  161. raise ValueError("autogenerated must be a boolean or object")
  162. config = cls._dict_secret_autogenerated(autogenerated_input, legacy_length, legacy_base64)
  163. return cls._validate_secret_autogenerated(variable_name, config)
  164. @staticmethod
  165. def _legacy_secret_autogenerated(
  166. legacy_length: Any,
  167. legacy_base64: bool,
  168. ) -> SecretAutogeneratedConfig | None:
  169. if not legacy_base64:
  170. return None
  171. return SecretAutogeneratedConfig(
  172. kind=SECRET_AUTOGENERATED_KIND_BASE64,
  173. bytes=int(legacy_length) if legacy_length is not None else DEFAULT_AUTOGENERATED_BYTES,
  174. )
  175. @classmethod
  176. def _boolean_secret_autogenerated(
  177. cls,
  178. variable_name: str,
  179. legacy_length: Any,
  180. legacy_base64: bool,
  181. ) -> SecretAutogeneratedConfig:
  182. legacy_config = cls._legacy_secret_autogenerated(legacy_length, legacy_base64)
  183. if legacy_config is not None:
  184. return legacy_config
  185. config = SecretAutogeneratedConfig()
  186. if legacy_length is not None:
  187. config.length = int(legacy_length)
  188. return cls._validate_secret_autogenerated(variable_name, config)
  189. @classmethod
  190. def _dict_secret_autogenerated(
  191. cls,
  192. autogenerated_input: dict[str, Any],
  193. legacy_length: Any,
  194. legacy_base64: bool,
  195. ) -> SecretAutogeneratedConfig:
  196. kind = str(
  197. autogenerated_input.get("kind")
  198. or (SECRET_AUTOGENERATED_KIND_BASE64 if legacy_base64 else SECRET_AUTOGENERATED_KIND_CHARACTERS)
  199. ).strip()
  200. config = SecretAutogeneratedConfig(kind=kind)
  201. if autogenerated_input.get("length") is not None:
  202. config.length = int(autogenerated_input["length"])
  203. elif legacy_length is not None and kind != SECRET_AUTOGENERATED_KIND_BASE64:
  204. config.length = int(legacy_length)
  205. if autogenerated_input.get("bytes") is not None:
  206. config.bytes = int(autogenerated_input["bytes"])
  207. if autogenerated_input.get("characters") is not None:
  208. config.characters = cls._normalize_secret_characters(autogenerated_input["characters"])
  209. return config
  210. @staticmethod
  211. def _normalize_secret_characters(characters: Any) -> list[str] | None:
  212. if not isinstance(characters, list):
  213. raise ValueError("autogenerated.characters must be a list")
  214. normalized_characters = []
  215. seen: set[str] = set()
  216. for char in characters:
  217. item = str(char).strip()
  218. if not item:
  219. continue
  220. if len(item) != 1:
  221. raise ValueError("autogenerated.characters entries must each be exactly one character")
  222. if item in seen:
  223. continue
  224. seen.add(item)
  225. normalized_characters.append(item)
  226. return normalized_characters or None
  227. @staticmethod
  228. def _validate_secret_autogenerated(
  229. variable_name: str,
  230. config: SecretAutogeneratedConfig,
  231. ) -> SecretAutogeneratedConfig:
  232. kind = (config.kind or SECRET_AUTOGENERATED_KIND_CHARACTERS).strip()
  233. if kind not in (SECRET_AUTOGENERATED_KIND_CHARACTERS, SECRET_AUTOGENERATED_KIND_BASE64):
  234. raise ValueError(
  235. f"variable '{variable_name}' autogenerated.kind must be one of "
  236. f"'{SECRET_AUTOGENERATED_KIND_CHARACTERS}' or '{SECRET_AUTOGENERATED_KIND_BASE64}'"
  237. )
  238. config.kind = kind
  239. if kind == SECRET_AUTOGENERATED_KIND_CHARACTERS:
  240. if config.bytes is not None:
  241. raise ValueError(f"variable '{variable_name}' character autogenerated secrets cannot define bytes")
  242. if config.length is not None and config.length <= 0:
  243. raise ValueError(f"variable '{variable_name}' autogenerated.length must be greater than 0")
  244. if config.characters is not None and not config.characters:
  245. raise ValueError(f"variable '{variable_name}' autogenerated.characters must not be empty")
  246. return config
  247. if config.length is not None:
  248. raise ValueError(
  249. f"variable '{variable_name}' base64 autogenerated secrets must use bytes instead of length"
  250. )
  251. if config.characters is not None:
  252. raise ValueError(f"variable '{variable_name}' base64 autogenerated secrets cannot define characters")
  253. if config.bytes is not None and config.bytes <= 0:
  254. raise ValueError(f"variable '{variable_name}' autogenerated.bytes must be greater than 0")
  255. return config
  256. @classmethod
  257. def _normalize_config(
  258. cls,
  259. variable_name: str,
  260. variable_type: str,
  261. config_input: Any,
  262. ) -> VariableConfig:
  263. if config_input is None:
  264. config_data: dict[str, Any] = {}
  265. elif isinstance(config_input, VariableConfig):
  266. return config_input.clone()
  267. elif isinstance(config_input, dict):
  268. config_data = config_input.copy()
  269. else:
  270. raise ValueError(f"Variable '{variable_name}' config must be a dictionary")
  271. options = cls._normalize_str_list(config_data.get("options"))
  272. placeholder = config_data.get("placeholder")
  273. placeholder = str(placeholder).strip() if placeholder not in (None, "") else None
  274. textarea = bool(config_data.get("textarea", False))
  275. unit = config_data.get("unit")
  276. unit = str(unit).strip() if unit not in (None, "") else None
  277. slider = bool(config_data.get("slider", False))
  278. min_value = config_data.get("min")
  279. max_value = config_data.get("max")
  280. step_value = config_data.get("step")
  281. min_int = int(min_value) if min_value is not None else None
  282. max_int = int(max_value) if max_value is not None else None
  283. step_int = int(step_value) if step_value is not None else None
  284. autogenerated_input = config_data.get("autogenerated")
  285. autogenerated_config = None
  286. if autogenerated_input not in (None, False) and variable_type != SECRET_TYPE:
  287. raise ValueError("autogenerated is only supported for secret variables")
  288. if variable_type == SECRET_TYPE:
  289. autogenerated_config = cls._parse_secret_autogenerated(
  290. variable_name,
  291. autogenerated_input,
  292. )
  293. config = VariableConfig(
  294. placeholder=placeholder,
  295. textarea=textarea,
  296. unit=unit,
  297. options=options,
  298. slider=slider,
  299. min=min_int,
  300. max=max_int,
  301. step=step_int,
  302. autogenerated=autogenerated_config,
  303. )
  304. if variable_type == "enum" and not config.options:
  305. raise ValueError("enum variables require non-empty options")
  306. if variable_type == "int" and config.slider:
  307. if config.min is None or config.max is None:
  308. raise ValueError("slider variables require min and max")
  309. if config.max < config.min:
  310. raise ValueError("slider variables require max >= min")
  311. if config.step is not None and config.step <= 0:
  312. raise ValueError("slider variables require step > 0")
  313. return config
  314. def is_secret(self) -> bool:
  315. return self.type == SECRET_TYPE
  316. def convert(self, value: Any) -> Any:
  317. if value is None:
  318. return None
  319. if isinstance(value, str) and value.strip() == "":
  320. return None
  321. converters = {
  322. "bool": self._convert_bool,
  323. "int": self._convert_int,
  324. "float": self._convert_float,
  325. "enum": self._convert_enum,
  326. "url": self._convert_url,
  327. "email": self._convert_email,
  328. }
  329. converter = converters.get(self.type)
  330. if converter:
  331. return converter(value)
  332. return str(value)
  333. def validate_and_convert(self, value: Any, check_required: bool = True) -> Any:
  334. converted = self.convert(value)
  335. if self.autogenerated and (converted is None or (isinstance(converted, str) and converted in {"", "*auto"})):
  336. return None
  337. if (
  338. check_required
  339. and self.is_required()
  340. and (converted is None or (isinstance(converted, str) and converted == ""))
  341. ):
  342. raise ValueError("This field is required and cannot be empty")
  343. if self.type == "int" and converted is not None and self.config.slider:
  344. self._validate_slider_value(converted)
  345. return converted
  346. def _convert_bool(self, value: Any) -> bool:
  347. if isinstance(value, bool):
  348. return value
  349. if isinstance(value, str):
  350. lowered = value.strip().lower()
  351. if lowered in TRUE_VALUES:
  352. return True
  353. if lowered in FALSE_VALUES:
  354. return False
  355. raise ValueError("value must be a boolean (true/false)")
  356. def _convert_int(self, value: Any) -> int | None:
  357. if isinstance(value, bool):
  358. raise ValueError("value must be an integer")
  359. if isinstance(value, int):
  360. return value
  361. if isinstance(value, str) and value.strip() == "":
  362. return None
  363. try:
  364. return int(value)
  365. except (TypeError, ValueError) as exc:
  366. raise ValueError("value must be an integer") from exc
  367. def _convert_float(self, value: Any) -> float | None:
  368. if isinstance(value, bool):
  369. raise ValueError("value must be a float")
  370. if isinstance(value, (int, float)):
  371. return float(value)
  372. if isinstance(value, str) and value.strip() == "":
  373. return None
  374. try:
  375. return float(value)
  376. except (TypeError, ValueError) as exc:
  377. raise ValueError("value must be a float") from exc
  378. def _convert_enum(self, value: Any) -> str | None:
  379. if value == "":
  380. return None
  381. val = str(value)
  382. if self.options and val not in self.options:
  383. raise ValueError(f"value must be one of: {', '.join(self.options)}")
  384. return val
  385. def _convert_url(self, value: Any) -> str | None:
  386. val = str(value).strip()
  387. if not val:
  388. return None
  389. parsed = urlparse(val)
  390. if not (parsed.scheme and parsed.netloc):
  391. raise ValueError("value must be a valid URL (include scheme and host)")
  392. return val
  393. def _convert_email(self, value: Any) -> str | None:
  394. val = str(value).strip()
  395. if not val:
  396. return None
  397. try:
  398. validated = validate_email(val, check_deliverability=False)
  399. return validated.normalized
  400. except EmailNotValidError as exc:
  401. raise ValueError(f"value must be a valid email address: {exc}") from exc
  402. def _validate_slider_value(self, value: int) -> None:
  403. if not self.config.slider:
  404. return
  405. min_value = self.config.min
  406. max_value = self.config.max
  407. if min_value is None or max_value is None:
  408. return
  409. if value < min_value:
  410. raise ValueError(f"value must be at least {min_value}")
  411. if value > max_value:
  412. raise ValueError(f"value must be at most {max_value}")
  413. step = self.config.step if self.config.step is not None else 1
  414. if step > 0 and (value - min_value) % step != 0:
  415. raise ValueError(f"value must align with step {step} starting at {min_value}")
  416. def to_dict(self) -> dict[str, Any]:
  417. result: dict[str, Any] = {}
  418. if self.type:
  419. result["type"] = self.type
  420. if self.value is not None:
  421. result["default"] = self.value
  422. for field in ("description", "prompt", "extra", "origin"):
  423. if value := getattr(self, field):
  424. result[field] = value
  425. if self.required:
  426. result["required"] = True
  427. config_dict = self._serialize_config()
  428. if config_dict:
  429. result["config"] = config_dict
  430. if self.needs:
  431. result["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs
  432. return result
  433. def _serialize_config(self) -> dict[str, Any]:
  434. if not self.config or self.config.is_empty():
  435. return {}
  436. config_dict: dict[str, Any] = {}
  437. value_fields = {
  438. "placeholder": self.config.placeholder,
  439. "unit": self.config.unit,
  440. "options": self.config.options,
  441. }
  442. for field_name, field_value in value_fields.items():
  443. if field_value:
  444. config_dict[field_name] = field_value
  445. if self.config.textarea:
  446. config_dict["textarea"] = True
  447. if self.config.slider:
  448. config_dict["slider"] = True
  449. for field_name in ("min", "max", "step"):
  450. field_value = getattr(self.config, field_name)
  451. if field_value is not None:
  452. config_dict[field_name] = field_value
  453. autogenerated_dict = self._serialize_autogenerated_config()
  454. if autogenerated_dict is not None:
  455. config_dict["autogenerated"] = autogenerated_dict
  456. return config_dict
  457. def _serialize_autogenerated_config(self) -> bool | dict[str, Any] | None:
  458. autogenerated = self.config.autogenerated
  459. if not autogenerated:
  460. return None
  461. if self._is_default_character_autogenerated(autogenerated):
  462. return True
  463. autogenerated_dict = {"kind": autogenerated.kind}
  464. for field_name in ("length", "characters", "bytes"):
  465. field_value = getattr(autogenerated, field_name)
  466. if field_value is not None:
  467. autogenerated_dict[field_name] = field_value
  468. return autogenerated_dict
  469. @staticmethod
  470. def _is_default_character_autogenerated(autogenerated: SecretAutogeneratedConfig) -> bool:
  471. return (
  472. autogenerated.kind == SECRET_AUTOGENERATED_KIND_CHARACTERS
  473. and autogenerated.length is None
  474. and autogenerated.characters is None
  475. and autogenerated.bytes is None
  476. )
  477. def get_display_value(self, mask_secret: bool = True, max_length: int = 30, show_none: bool = True) -> str:
  478. if self.value is None or self.value == "":
  479. if self.autogenerated:
  480. return "[dim](*auto)[/dim]" if show_none else ""
  481. return "[dim](none)[/dim]" if show_none else ""
  482. if self.is_secret() and mask_secret:
  483. return "********"
  484. display = str(self.value)
  485. if max_length > 0 and len(display) > max_length:
  486. return display[: max_length - 3] + "..."
  487. return display
  488. def get_normalized_default(self) -> Any:
  489. typed = self._coerce_default_value()
  490. if self.autogenerated and not typed:
  491. return "*auto"
  492. normalizers = {
  493. "enum": self._normalize_enum_default,
  494. "bool": self._normalize_bool_default,
  495. "int": lambda value: self._normalize_numeric_default(value, int),
  496. "float": lambda value: self._normalize_numeric_default(value, float),
  497. }
  498. normalizer = normalizers.get(self.type, self._normalize_string_default)
  499. return normalizer(typed)
  500. def _coerce_default_value(self) -> Any:
  501. try:
  502. return self.convert(self.value)
  503. except Exception:
  504. return self.value
  505. def _normalize_enum_default(self, typed: Any) -> Any:
  506. if not self.options:
  507. return typed
  508. if typed is None or str(typed) not in self.options:
  509. return self.options[0]
  510. return str(typed)
  511. @staticmethod
  512. def _normalize_bool_default(typed: Any) -> bool | None:
  513. if isinstance(typed, bool):
  514. return typed
  515. if typed is None:
  516. return None
  517. return bool(typed)
  518. @staticmethod
  519. def _normalize_numeric_default(typed: Any, caster) -> Any:
  520. try:
  521. return caster(typed) if typed not in (None, "") else None
  522. except Exception:
  523. return None
  524. @staticmethod
  525. def _normalize_string_default(typed: Any) -> str | None:
  526. return None if typed is None else str(typed)
  527. def get_prompt_text(self) -> str:
  528. prompt_text = self.prompt or self.description or self.name
  529. if self.value is not None and self.type in ["email", "url"]:
  530. prompt_text += f" ({self.type})"
  531. return prompt_text
  532. def get_validation_hint(self) -> str | None:
  533. hints = []
  534. if self.type == "enum" and self.options:
  535. hints.append(f"Options: {', '.join(self.options)}")
  536. if self.type == "int" and self.config.slider and self.config.min is not None and self.config.max is not None:
  537. slider_hint = f"Range: {self.config.min}..{self.config.max}"
  538. step = self.config.step if self.config.step is not None else 1
  539. if step != 1:
  540. slider_hint += f", step {step}"
  541. if self.config.unit:
  542. slider_hint += f" {self.config.unit}"
  543. hints.append(slider_hint)
  544. elif self.type == "int" and self.config.unit:
  545. hints.append(f"Unit: {self.config.unit}")
  546. if self.autogenerated:
  547. if self.autogenerated_base64:
  548. bytes_value = (
  549. self.autogenerated_config.bytes_or_default()
  550. if self.autogenerated_config
  551. else DEFAULT_AUTOGENERATED_BYTES
  552. )
  553. hints.append(f"Auto-generated base64 secret ({bytes_value} bytes) if empty")
  554. else:
  555. length = (
  556. self.autogenerated_config.length_or_default()
  557. if self.autogenerated_config
  558. else DEFAULT_AUTOGENERATED_LENGTH
  559. )
  560. hints.append(f"Auto-generated secret (length {length}) if empty")
  561. if self.extra:
  562. hints.append(self.extra)
  563. return " — ".join(hints) if hints else None
  564. def is_required(self) -> bool:
  565. return self.required and not self.autogenerated
  566. def get_parent(self) -> VariableSection | None:
  567. return self.parent_section
  568. def clone(self, update: dict[str, Any] | None = None) -> Variable:
  569. data = {
  570. "name": self.name,
  571. "type": self.type,
  572. "value": self.value,
  573. "description": self.description,
  574. "prompt": self.prompt,
  575. "origin": self.origin,
  576. "extra": self.extra,
  577. "required": self.required,
  578. "original_value": self.original_value,
  579. "needs": self.needs.copy() if self.needs else None,
  580. "parent_section": self.parent_section,
  581. "config": self.config.clone() if self.config else None,
  582. }
  583. if update:
  584. data.update(update)
  585. cloned = Variable(data)
  586. cloned._explicit_fields = self._explicit_fields.copy()
  587. if update:
  588. cloned._explicit_fields.update(update.keys())
  589. return cloned