variable.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  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, dict):
  266. config_data = config_input.copy()
  267. else:
  268. raise ValueError(f"Variable '{variable_name}' config must be a dictionary")
  269. options = cls._normalize_str_list(config_data.get("options"))
  270. placeholder = config_data.get("placeholder")
  271. placeholder = str(placeholder).strip() if placeholder not in (None, "") else None
  272. textarea = bool(config_data.get("textarea", False))
  273. unit = config_data.get("unit")
  274. unit = str(unit).strip() if unit not in (None, "") else None
  275. slider = bool(config_data.get("slider", False))
  276. min_value = config_data.get("min")
  277. max_value = config_data.get("max")
  278. step_value = config_data.get("step")
  279. min_int = int(min_value) if min_value is not None else None
  280. max_int = int(max_value) if max_value is not None else None
  281. step_int = int(step_value) if step_value is not None else None
  282. autogenerated_input = config_data.get("autogenerated")
  283. autogenerated_config = None
  284. if autogenerated_input not in (None, False) and variable_type != SECRET_TYPE:
  285. raise ValueError("autogenerated is only supported for secret variables")
  286. if variable_type == SECRET_TYPE:
  287. autogenerated_config = cls._parse_secret_autogenerated(
  288. variable_name,
  289. autogenerated_input,
  290. )
  291. config = VariableConfig(
  292. placeholder=placeholder,
  293. textarea=textarea,
  294. unit=unit,
  295. options=options,
  296. slider=slider,
  297. min=min_int,
  298. max=max_int,
  299. step=step_int,
  300. autogenerated=autogenerated_config,
  301. )
  302. if variable_type == "enum" and not config.options:
  303. raise ValueError("enum variables require non-empty options")
  304. if variable_type == "int" and config.slider:
  305. if config.min is None or config.max is None:
  306. raise ValueError("slider variables require min and max")
  307. if config.max < config.min:
  308. raise ValueError("slider variables require max >= min")
  309. if config.step is not None and config.step <= 0:
  310. raise ValueError("slider variables require step > 0")
  311. return config
  312. def is_secret(self) -> bool:
  313. return self.type == SECRET_TYPE
  314. def convert(self, value: Any) -> Any:
  315. if value is None:
  316. return None
  317. if isinstance(value, str) and value.strip() == "":
  318. return None
  319. converters = {
  320. "bool": self._convert_bool,
  321. "int": self._convert_int,
  322. "float": self._convert_float,
  323. "enum": self._convert_enum,
  324. "url": self._convert_url,
  325. "email": self._convert_email,
  326. }
  327. converter = converters.get(self.type)
  328. if converter:
  329. return converter(value)
  330. return str(value)
  331. def validate_and_convert(self, value: Any, check_required: bool = True) -> Any:
  332. converted = self.convert(value)
  333. if self.autogenerated and (converted is None or (isinstance(converted, str) and converted in {"", "*auto"})):
  334. return None
  335. if (
  336. check_required
  337. and self.is_required()
  338. and (converted is None or (isinstance(converted, str) and converted == ""))
  339. ):
  340. raise ValueError("This field is required and cannot be empty")
  341. if self.type == "int" and converted is not None and self.config.slider:
  342. self._validate_slider_value(converted)
  343. return converted
  344. def _convert_bool(self, value: Any) -> bool:
  345. if isinstance(value, bool):
  346. return value
  347. if isinstance(value, str):
  348. lowered = value.strip().lower()
  349. if lowered in TRUE_VALUES:
  350. return True
  351. if lowered in FALSE_VALUES:
  352. return False
  353. raise ValueError("value must be a boolean (true/false)")
  354. def _convert_int(self, value: Any) -> int | None:
  355. if isinstance(value, bool):
  356. raise ValueError("value must be an integer")
  357. if isinstance(value, int):
  358. return value
  359. if isinstance(value, str) and value.strip() == "":
  360. return None
  361. try:
  362. return int(value)
  363. except (TypeError, ValueError) as exc:
  364. raise ValueError("value must be an integer") from exc
  365. def _convert_float(self, value: Any) -> float | None:
  366. if isinstance(value, bool):
  367. raise ValueError("value must be a float")
  368. if isinstance(value, (int, float)):
  369. return float(value)
  370. if isinstance(value, str) and value.strip() == "":
  371. return None
  372. try:
  373. return float(value)
  374. except (TypeError, ValueError) as exc:
  375. raise ValueError("value must be a float") from exc
  376. def _convert_enum(self, value: Any) -> str | None:
  377. if value == "":
  378. return None
  379. val = str(value)
  380. if self.options and val not in self.options:
  381. raise ValueError(f"value must be one of: {', '.join(self.options)}")
  382. return val
  383. def _convert_url(self, value: Any) -> str | None:
  384. val = str(value).strip()
  385. if not val:
  386. return None
  387. parsed = urlparse(val)
  388. if not (parsed.scheme and parsed.netloc):
  389. raise ValueError("value must be a valid URL (include scheme and host)")
  390. return val
  391. def _convert_email(self, value: Any) -> str | None:
  392. val = str(value).strip()
  393. if not val:
  394. return None
  395. try:
  396. validated = validate_email(val, check_deliverability=False)
  397. return validated.normalized
  398. except EmailNotValidError as exc:
  399. raise ValueError(f"value must be a valid email address: {exc}") from exc
  400. def _validate_slider_value(self, value: int) -> None:
  401. if not self.config.slider:
  402. return
  403. min_value = self.config.min
  404. max_value = self.config.max
  405. if min_value is None or max_value is None:
  406. return
  407. if value < min_value:
  408. raise ValueError(f"value must be at least {min_value}")
  409. if value > max_value:
  410. raise ValueError(f"value must be at most {max_value}")
  411. step = self.config.step if self.config.step is not None else 1
  412. if step > 0 and (value - min_value) % step != 0:
  413. raise ValueError(f"value must align with step {step} starting at {min_value}")
  414. def to_dict(self) -> dict[str, Any]:
  415. result: dict[str, Any] = {}
  416. if self.type:
  417. result["type"] = self.type
  418. if self.value is not None:
  419. result["default"] = self.value
  420. for field in ("description", "prompt", "extra", "origin"):
  421. if value := getattr(self, field):
  422. result[field] = value
  423. if self.required:
  424. result["required"] = True
  425. config_dict = self._serialize_config()
  426. if config_dict:
  427. result["config"] = config_dict
  428. if self.needs:
  429. result["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs
  430. return result
  431. def _serialize_config(self) -> dict[str, Any]:
  432. if not self.config or self.config.is_empty():
  433. return {}
  434. config_dict: dict[str, Any] = {}
  435. value_fields = {
  436. "placeholder": self.config.placeholder,
  437. "unit": self.config.unit,
  438. "options": self.config.options,
  439. }
  440. for field_name, field_value in value_fields.items():
  441. if field_value:
  442. config_dict[field_name] = field_value
  443. if self.config.textarea:
  444. config_dict["textarea"] = True
  445. if self.config.slider:
  446. config_dict["slider"] = True
  447. for field_name in ("min", "max", "step"):
  448. field_value = getattr(self.config, field_name)
  449. if field_value is not None:
  450. config_dict[field_name] = field_value
  451. autogenerated_dict = self._serialize_autogenerated_config()
  452. if autogenerated_dict is not None:
  453. config_dict["autogenerated"] = autogenerated_dict
  454. return config_dict
  455. def _serialize_autogenerated_config(self) -> bool | dict[str, Any] | None:
  456. autogenerated = self.config.autogenerated
  457. if not autogenerated:
  458. return None
  459. if self._is_default_character_autogenerated(autogenerated):
  460. return True
  461. autogenerated_dict = {"kind": autogenerated.kind}
  462. for field_name in ("length", "characters", "bytes"):
  463. field_value = getattr(autogenerated, field_name)
  464. if field_value is not None:
  465. autogenerated_dict[field_name] = field_value
  466. return autogenerated_dict
  467. @staticmethod
  468. def _is_default_character_autogenerated(autogenerated: SecretAutogeneratedConfig) -> bool:
  469. return (
  470. autogenerated.kind == SECRET_AUTOGENERATED_KIND_CHARACTERS
  471. and autogenerated.length is None
  472. and autogenerated.characters is None
  473. and autogenerated.bytes is None
  474. )
  475. def get_display_value(self, mask_secret: bool = True, max_length: int = 30, show_none: bool = True) -> str:
  476. if self.value is None or self.value == "":
  477. if self.autogenerated:
  478. return "[dim](*auto)[/dim]" if show_none else ""
  479. return "[dim](none)[/dim]" if show_none else ""
  480. if self.is_secret() and mask_secret:
  481. return "********"
  482. display = str(self.value)
  483. if max_length > 0 and len(display) > max_length:
  484. return display[: max_length - 3] + "..."
  485. return display
  486. def get_normalized_default(self) -> Any:
  487. typed = self._coerce_default_value()
  488. if self.autogenerated and not typed:
  489. return "*auto"
  490. normalizers = {
  491. "enum": self._normalize_enum_default,
  492. "bool": self._normalize_bool_default,
  493. "int": lambda value: self._normalize_numeric_default(value, int),
  494. "float": lambda value: self._normalize_numeric_default(value, float),
  495. }
  496. normalizer = normalizers.get(self.type, self._normalize_string_default)
  497. return normalizer(typed)
  498. def _coerce_default_value(self) -> Any:
  499. try:
  500. return self.convert(self.value)
  501. except Exception:
  502. return self.value
  503. def _normalize_enum_default(self, typed: Any) -> Any:
  504. if not self.options:
  505. return typed
  506. if typed is None or str(typed) not in self.options:
  507. return self.options[0]
  508. return str(typed)
  509. @staticmethod
  510. def _normalize_bool_default(typed: Any) -> bool | None:
  511. if isinstance(typed, bool):
  512. return typed
  513. if typed is None:
  514. return None
  515. return bool(typed)
  516. @staticmethod
  517. def _normalize_numeric_default(typed: Any, caster) -> Any:
  518. try:
  519. return caster(typed) if typed not in (None, "") else None
  520. except Exception:
  521. return None
  522. @staticmethod
  523. def _normalize_string_default(typed: Any) -> str | None:
  524. return None if typed is None else str(typed)
  525. def get_prompt_text(self) -> str:
  526. prompt_text = self.prompt or self.description or self.name
  527. if self.value is not None and self.type in ["email", "url"]:
  528. prompt_text += f" ({self.type})"
  529. return prompt_text
  530. def get_validation_hint(self) -> str | None:
  531. hints = []
  532. if self.type == "enum" and self.options:
  533. hints.append(f"Options: {', '.join(self.options)}")
  534. if self.type == "int" and self.config.slider and self.config.min is not None and self.config.max is not None:
  535. slider_hint = f"Range: {self.config.min}..{self.config.max}"
  536. step = self.config.step if self.config.step is not None else 1
  537. if step != 1:
  538. slider_hint += f", step {step}"
  539. if self.config.unit:
  540. slider_hint += f" {self.config.unit}"
  541. hints.append(slider_hint)
  542. elif self.type == "int" and self.config.unit:
  543. hints.append(f"Unit: {self.config.unit}")
  544. if self.autogenerated:
  545. if self.autogenerated_base64:
  546. bytes_value = (
  547. self.autogenerated_config.bytes_or_default()
  548. if self.autogenerated_config
  549. else DEFAULT_AUTOGENERATED_BYTES
  550. )
  551. hints.append(f"Auto-generated base64 secret ({bytes_value} bytes) if empty")
  552. else:
  553. length = (
  554. self.autogenerated_config.length_or_default()
  555. if self.autogenerated_config
  556. else DEFAULT_AUTOGENERATED_LENGTH
  557. )
  558. hints.append(f"Auto-generated secret (length {length}) if empty")
  559. if self.extra:
  560. hints.append(self.extra)
  561. return " — ".join(hints) if hints else None
  562. def is_required(self) -> bool:
  563. return self.required and not self.autogenerated
  564. def get_parent(self) -> VariableSection | None:
  565. return self.parent_section
  566. def clone(self, update: dict[str, Any] | None = None) -> Variable:
  567. data = {
  568. "name": self.name,
  569. "type": self.type,
  570. "value": self.value,
  571. "description": self.description,
  572. "prompt": self.prompt,
  573. "origin": self.origin,
  574. "extra": self.extra,
  575. "required": self.required,
  576. "original_value": self.original_value,
  577. "needs": self.needs.copy() if self.needs else None,
  578. "parent_section": self.parent_section,
  579. "config": self.config.clone() if self.config else None,
  580. }
  581. if update:
  582. data.update(update)
  583. cloned = Variable(data)
  584. cloned._explicit_fields = self._explicit_fields.copy()
  585. if update:
  586. cloned._explicit_fields.update(update.keys())
  587. return cloned