template.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. from __future__ import annotations
  2. import base64
  3. import json
  4. import logging
  5. import os
  6. import re
  7. import secrets
  8. import string
  9. from dataclasses import dataclass, field
  10. from pathlib import Path
  11. from typing import Any
  12. from jinja2 import Environment, FileSystemLoader, meta
  13. from jinja2.exceptions import TemplateError as Jinja2TemplateError
  14. from jinja2.exceptions import TemplateNotFound as Jinja2TemplateNotFound
  15. from jinja2.exceptions import TemplateSyntaxError as Jinja2TemplateSyntaxError
  16. from jinja2.exceptions import UndefinedError
  17. from jinja2.sandbox import SandboxedEnvironment
  18. from ..exceptions import RenderErrorContext, TemplateLoadError, TemplateRenderError, TemplateValidationError
  19. from .variable_collection import VariableCollection
  20. logger = logging.getLogger(__name__)
  21. TEMPLATE_MANIFEST_FILENAME = "template.json"
  22. LEGACY_TEMPLATE_FILENAMES = ("template.yaml", "template.yml")
  23. TEMPLATE_FILES_DIRNAME = "files"
  24. VARIABLE_START = "<<"
  25. VARIABLE_END = ">>"
  26. BLOCK_START = "<%"
  27. BLOCK_END = "%>"
  28. COMMENT_START = "<#"
  29. COMMENT_END = "#>"
  30. def normalize_template_slug(slug: str, kind: str | None = None) -> str:
  31. """Normalize a manifest slug for CLI use.
  32. If the slug ends with "-<kind>", remove that redundant suffix.
  33. Example: "portainer-compose" -> "portainer" for kind "compose".
  34. """
  35. normalized_slug = str(slug).strip()
  36. normalized_kind = str(kind or "").strip()
  37. if not normalized_slug:
  38. return normalized_slug
  39. suffix = f"-{normalized_kind}" if normalized_kind else ""
  40. if suffix and normalized_slug.endswith(suffix):
  41. return normalized_slug[: -len(suffix)]
  42. return normalized_slug
  43. class TemplateErrorHandler:
  44. """Parses Jinja rendering errors into user-friendly context."""
  45. @staticmethod
  46. def extract_error_context(file_path: Path, line_number: int | None, context_size: int = 3) -> list[str]:
  47. """Extract lines around a rendering error."""
  48. if not line_number or not file_path.exists():
  49. return []
  50. try:
  51. with file_path.open(encoding="utf-8") as file_handle:
  52. lines = file_handle.readlines()
  53. except OSError:
  54. return []
  55. start_line = max(0, line_number - context_size - 1)
  56. end_line = min(len(lines), line_number + context_size)
  57. context = []
  58. for index in range(start_line, end_line):
  59. display_line = index + 1
  60. marker = ">>>" if display_line == line_number else " "
  61. context.append(f"{marker} {display_line:4d} | {lines[index].rstrip()}")
  62. return context
  63. @staticmethod
  64. def get_common_suggestions(error_msg: str, available_vars: set[str]) -> list[str]:
  65. """Build action-oriented suggestions for common rendering failures."""
  66. suggestions = []
  67. error_lower = error_msg.lower()
  68. if "undefined" in error_lower or "is not defined" in error_lower:
  69. var_match = re.search(r"'([^']+)'.*is undefined", error_msg)
  70. if not var_match:
  71. var_match = re.search(r"'([^']+)'.*is not defined", error_msg)
  72. if var_match:
  73. undefined_var = var_match.group(1)
  74. suggestions.append(f"Variable '{undefined_var}' is not defined in template.json")
  75. similar = [
  76. candidate
  77. for candidate in available_vars
  78. if undefined_var.lower() in candidate.lower() or candidate.lower() in undefined_var.lower()
  79. ]
  80. if similar:
  81. suggestions.append(f"Did you mean: {', '.join(sorted(similar)[:5])}")
  82. suggestions.append("Declare the variable under variables[].items in template.json")
  83. suggestions.append(f"Or make it optional with << {undefined_var} | default('value') >>")
  84. else:
  85. suggestions.append("Check that every rendered variable is declared in template.json")
  86. elif "unexpected" in error_lower or "expected" in error_lower:
  87. suggestions.append("Check template control-flow syntax with the new delimiters")
  88. suggestions.append("Use <% %> for blocks, << >> for variables, and <# #> for comments")
  89. elif "not found" in error_lower or "does not exist" in error_lower:
  90. suggestions.append("Check included/imported files relative to the template's files/ directory")
  91. else:
  92. suggestions.append("Inspect template syntax and variable usage")
  93. if not suggestions:
  94. suggestions.append("Enable --log-level DEBUG for more detail")
  95. return suggestions
  96. @classmethod
  97. def parse_jinja_error(
  98. cls,
  99. error: Exception,
  100. template_file: TemplateFile,
  101. files_dir: Path,
  102. available_vars: set[str],
  103. ) -> tuple[str, int | None, int | None, list[str], list[str]]:
  104. """Parse a Jinja exception into structured display data."""
  105. error_message = str(error)
  106. line_number = getattr(error, "lineno", None)
  107. file_path = files_dir / template_file.relative_path
  108. context_lines = cls.extract_error_context(file_path, line_number)
  109. suggestions = cls.get_common_suggestions(error_message, available_vars)
  110. if isinstance(error, UndefinedError):
  111. error_message = f"Undefined variable: {error}"
  112. elif isinstance(error, Jinja2TemplateSyntaxError):
  113. error_message = f"Template syntax error: {error}"
  114. elif isinstance(error, Jinja2TemplateNotFound):
  115. error_message = f"Template file not found: {error}"
  116. return error_message, line_number, None, context_lines, suggestions
  117. @dataclass
  118. class TemplateFile:
  119. """Represents a renderable template file."""
  120. relative_path: Path
  121. output_path: Path
  122. @dataclass
  123. class TemplateVersionMetadata:
  124. """Structured version metadata extracted from template.json."""
  125. name: str = ""
  126. source_dep_name: str = ""
  127. source_dep_version: str = ""
  128. source_dep_digest: str = ""
  129. upstream_ref: str = ""
  130. notes: str = ""
  131. def __bool__(self) -> bool:
  132. """Treat the version as present for display when a name exists."""
  133. return bool(self.name)
  134. def __str__(self) -> str:
  135. """Render the user-facing version label."""
  136. return self.name
  137. @classmethod
  138. def from_metadata(cls, metadata: dict[str, Any]) -> TemplateVersionMetadata:
  139. """Parse the optional metadata.version object."""
  140. version_data = metadata.get("version")
  141. if version_data is None:
  142. return cls()
  143. if not isinstance(version_data, dict):
  144. raise TemplateValidationError("Template format error: 'metadata.version' must be an object")
  145. return cls(
  146. name=str(version_data.get("name", "")).strip(),
  147. source_dep_name=str(version_data.get("source_dep_name", "")).strip(),
  148. source_dep_version=str(version_data.get("source_dep_version", "")).strip(),
  149. source_dep_digest=str(version_data.get("source_dep_digest", "")).strip(),
  150. upstream_ref=str(version_data.get("upstream_ref", "")).strip(),
  151. notes=str(version_data.get("notes", "")).rstrip("\n"),
  152. )
  153. @dataclass
  154. class TemplateMetadata:
  155. """Typed template metadata extracted from template.json."""
  156. name: str
  157. description: str
  158. author: str
  159. date: str
  160. version: TemplateVersionMetadata = field(default_factory=TemplateVersionMetadata)
  161. module: str = ""
  162. tags: list[str] = field(default_factory=list)
  163. library: str = "unknown"
  164. library_type: str = "git"
  165. draft: bool = False
  166. icon: dict[str, Any] = field(default_factory=dict)
  167. def __init__(
  168. self,
  169. template_data: dict[str, Any],
  170. library_name: str | None = None,
  171. library_type: str = "git",
  172. ) -> None:
  173. metadata = template_data.get("metadata")
  174. if not isinstance(metadata, dict):
  175. raise TemplateValidationError("Template format error: missing 'metadata' object in template.json")
  176. self.name = str(metadata.get("name", "")).strip()
  177. self.description = str(metadata.get("description", "")).rstrip("\n")
  178. self.author = str(metadata.get("author", "")).strip()
  179. self.date = str(metadata.get("date", "")).strip()
  180. self.version = TemplateVersionMetadata.from_metadata(metadata)
  181. self.module = str(template_data.get("kind", "")).strip()
  182. self.tags = metadata.get("tags", []) if isinstance(metadata.get("tags", []), list) else []
  183. self.library = library_name or "unknown"
  184. self.library_type = library_type
  185. self.draft = bool(metadata.get("draft", False))
  186. self.icon = metadata.get("icon", {}) if isinstance(metadata.get("icon"), dict) else {}
  187. class Template:
  188. """Loads, validates, and renders template.json-based templates."""
  189. def __init__(self, template_dir: Path, library_name: str, library_type: str = "git") -> None:
  190. self.template_dir = template_dir
  191. self.directory_id = template_dir.name
  192. self.id = template_dir.name
  193. self.original_id = template_dir.name
  194. self.library_name = library_name
  195. self.library_type = library_type
  196. self.__jinja_env: Environment | None = None
  197. self.__used_variables: set[str] | None = None
  198. self.__variables: VariableCollection | None = None
  199. self.__template_files: list[TemplateFile] | None = None
  200. try:
  201. manifest_path = self._find_manifest_file()
  202. with manifest_path.open(encoding="utf-8") as file_handle:
  203. self._template_data = json.load(file_handle)
  204. if not isinstance(self._template_data, dict):
  205. raise TemplateValidationError("Template format error: template.json must contain a JSON object")
  206. self.metadata = TemplateMetadata(self._template_data, library_name, library_type)
  207. self._validate_kind(self._template_data)
  208. self.slug = self._get_template_slug(self._template_data, self.directory_id)
  209. self.id = self.slug
  210. self.original_id = self.slug
  211. self.files_dir = self.template_dir / TEMPLATE_FILES_DIRNAME
  212. if not self.files_dir.is_dir():
  213. raise TemplateValidationError(
  214. f"Template '{self.id}' is missing required '{TEMPLATE_FILES_DIRNAME}/' directory"
  215. )
  216. self._validate_template_manifest()
  217. logger.info("Loaded template '%s' (version=%s)", self.id, self.metadata.version or "unknown")
  218. except (json.JSONDecodeError, TemplateValidationError, FileNotFoundError) as exc:
  219. logger.error("Error loading template from %s: %s", template_dir, exc)
  220. raise TemplateLoadError(f"Error loading template from {template_dir}: {exc}") from exc
  221. except OSError as exc:
  222. logger.error("File I/O error loading template %s: %s", template_dir, exc)
  223. raise TemplateLoadError(f"File I/O error loading template from {template_dir}: {exc}") from exc
  224. def set_qualified_id(self, library_name: str | None = None) -> None:
  225. """Set a qualified template ID when duplicates exist across libraries."""
  226. lib_name = library_name or self.library_name
  227. self.id = f"{self.original_id}.{lib_name}"
  228. def _find_manifest_file(self) -> Path:
  229. """Locate template.json and reject legacy template manifests."""
  230. manifest_path = self.template_dir / TEMPLATE_MANIFEST_FILENAME
  231. if manifest_path.exists():
  232. return manifest_path
  233. for legacy_name in LEGACY_TEMPLATE_FILENAMES:
  234. legacy_path = self.template_dir / legacy_name
  235. if legacy_path.exists():
  236. raise TemplateValidationError(
  237. "Legacy template manifests are incompatible with boilerplates 0.2.0. "
  238. f"Replace '{legacy_name}' with '{TEMPLATE_MANIFEST_FILENAME}' and move renderable files into "
  239. f"'{TEMPLATE_FILES_DIRNAME}/'."
  240. )
  241. raise FileNotFoundError(f"Main template file ({TEMPLATE_MANIFEST_FILENAME}) not found in {self.template_dir}")
  242. def _validate_template_manifest(self) -> None:
  243. """Validate required top-level manifest structure."""
  244. variables = self._template_data.get("variables", [])
  245. if not isinstance(variables, list):
  246. raise TemplateValidationError("Template format error: 'variables' must be a list")
  247. @staticmethod
  248. def _validate_kind(template_data: dict[str, Any]) -> None:
  249. """Validate presence of the template kind."""
  250. if not template_data.get("kind"):
  251. raise TemplateValidationError("Template format error: missing 'kind' field")
  252. @staticmethod
  253. def _get_template_slug(template_data: dict[str, Any], fallback: str) -> str:
  254. """Resolve the canonical template ID from the manifest slug."""
  255. manifest_slug = str(template_data.get("slug", "")).strip()
  256. kind = str(template_data.get("kind", "")).strip()
  257. if not manifest_slug:
  258. return fallback
  259. return normalize_template_slug(manifest_slug, kind)
  260. @staticmethod
  261. def _create_jinja_env(search_path: Path) -> SandboxedEnvironment:
  262. """Create the custom-delimiter Jinja environment for template rendering."""
  263. return SandboxedEnvironment(
  264. loader=FileSystemLoader(search_path),
  265. autoescape=False,
  266. variable_start_string=VARIABLE_START,
  267. variable_end_string=VARIABLE_END,
  268. block_start_string=BLOCK_START,
  269. block_end_string=BLOCK_END,
  270. comment_start_string=COMMENT_START,
  271. comment_end_string=COMMENT_END,
  272. keep_trailing_newline=True,
  273. trim_blocks=False,
  274. lstrip_blocks=False,
  275. )
  276. def _collect_template_files(self) -> None:
  277. """Collect every renderable file under files/."""
  278. template_files: list[TemplateFile] = []
  279. for root, _, files in os.walk(self.files_dir):
  280. for filename in files:
  281. absolute_path = Path(root) / filename
  282. relative_path = absolute_path.relative_to(self.files_dir)
  283. template_files.append(
  284. TemplateFile(
  285. relative_path=relative_path,
  286. output_path=relative_path,
  287. )
  288. )
  289. template_files.sort(key=lambda item: str(item.relative_path))
  290. self.__template_files = template_files
  291. def _extract_all_used_variables(self) -> set[str]:
  292. """Extract undeclared variables from all files under files/."""
  293. used_variables: set[str] = set()
  294. syntax_errors = []
  295. self._variable_usage_map: dict[str, list[str]] = {}
  296. for template_file in self.template_files:
  297. file_path = self.files_dir / template_file.relative_path
  298. try:
  299. content = file_path.read_text(encoding="utf-8")
  300. ast = self.jinja_env.parse(content)
  301. file_variables = meta.find_undeclared_variables(ast)
  302. used_variables.update(file_variables)
  303. for variable_name in file_variables:
  304. self._variable_usage_map.setdefault(variable_name, []).append(str(template_file.relative_path))
  305. except Jinja2TemplateSyntaxError as exc:
  306. syntax_errors.append(f"{template_file.relative_path}:{exc.lineno}: {exc.message}")
  307. except OSError as exc:
  308. raise TemplateValidationError(
  309. f"Failed to read template file '{template_file.relative_path}': {exc}"
  310. ) from exc
  311. if syntax_errors:
  312. raise TemplateValidationError("Template syntax validation failed:\n" + "\n".join(sorted(syntax_errors)))
  313. return used_variables
  314. @staticmethod
  315. def _merge_item_config(item_data: dict[str, Any]) -> dict[str, Any]:
  316. """Flatten manifest item fields into the VariableCollection runtime shape."""
  317. if not isinstance(item_data, dict):
  318. raise TemplateValidationError("Variable items must be objects")
  319. if "name" not in item_data:
  320. raise TemplateValidationError("Variable item missing required 'name' field")
  321. item_type = item_data.get("type", "str")
  322. item_config = item_data.get("config", {})
  323. if item_config is not None and not isinstance(item_config, dict):
  324. raise TemplateValidationError(f"Variable '{item_data['name']}' config must be an object")
  325. normalized = {"type": item_type}
  326. field_map = {
  327. "default": "default",
  328. "value": "value",
  329. "required": "required",
  330. "needs": "needs",
  331. "extra": "extra",
  332. }
  333. for source_key, target_key in field_map.items():
  334. if source_key in item_data:
  335. normalized[target_key] = item_data[source_key]
  336. description = item_data.get("description") or item_data.get("title")
  337. if description is not None:
  338. normalized["description"] = description
  339. if "title" in item_data:
  340. normalized["prompt"] = item_data["title"]
  341. config_value = item_data.get("config", item_config)
  342. if config_value:
  343. normalized["config"] = config_value
  344. return normalized
  345. def _normalize_manifest_variables(self) -> dict[str, Any]:
  346. """Convert variables[].items manifest structure into VariableCollection format."""
  347. spec: dict[str, Any] = {}
  348. for group_data in self._template_data.get("variables", []):
  349. if not isinstance(group_data, dict):
  350. raise TemplateValidationError("Variable groups must be objects")
  351. if "name" not in group_data:
  352. raise TemplateValidationError("Variable group missing required 'name' field")
  353. if "title" not in group_data:
  354. raise TemplateValidationError(f"Variable group '{group_data['name']}' missing required 'title' field")
  355. group_name = group_data["name"]
  356. items = group_data.get("items")
  357. if not isinstance(items, list):
  358. raise TemplateValidationError(f"Variable group '{group_name}' must define an 'items' array")
  359. section_data: dict[str, Any] = {
  360. "title": group_data["title"],
  361. "vars": {},
  362. }
  363. for optional_key in ("description", "toggle", "needs"):
  364. if optional_key in group_data:
  365. section_data[optional_key] = group_data[optional_key]
  366. for item_data in items:
  367. normalized_item = self._merge_item_config(item_data)
  368. variable_name = item_data["name"]
  369. section_data["vars"][variable_name] = normalized_item
  370. spec[group_name] = section_data
  371. return spec
  372. def _validate_variable_definitions(self, used_variables: set[str], spec: dict[str, Any]) -> None:
  373. """Validate that all rendered variables are declared in the manifest."""
  374. defined_variables = set()
  375. for section_data in spec.values():
  376. defined_variables.update((section_data.get("vars") or {}).keys())
  377. undefined_variables = used_variables - defined_variables
  378. if not undefined_variables:
  379. return
  380. undefined_list = sorted(undefined_variables)
  381. file_locations = []
  382. for variable_name in undefined_list:
  383. if variable_name in getattr(self, "_variable_usage_map", {}):
  384. locations = ", ".join(self._variable_usage_map[variable_name])
  385. file_locations.append(f" - {variable_name}: {locations}")
  386. error_lines = [
  387. f"Template validation error in '{self.id}': variables used in files/ but not declared in template.json."
  388. ]
  389. if file_locations:
  390. error_lines.extend(file_locations)
  391. else:
  392. error_lines.append(", ".join(undefined_list))
  393. error_lines.extend(
  394. [
  395. "",
  396. "Declare missing variables under variables[].items in template.json.",
  397. "Example:",
  398. "{",
  399. ' "variables": [',
  400. " {",
  401. ' "name": "general",',
  402. ' "title": "General",',
  403. ' "items": [',
  404. ' { "name": "missing_var", "type": "str", "title": "Missing var" }',
  405. " ]",
  406. " }",
  407. " ]",
  408. "}",
  409. ]
  410. )
  411. raise TemplateValidationError("\n".join(error_lines))
  412. def _generate_autogenerated_values(
  413. self,
  414. variables: VariableCollection,
  415. variable_values: dict[str, Any],
  416. ) -> None:
  417. """Populate autogenerated values for empty variables."""
  418. for variable in variables._variable_map.values():
  419. if not variable.autogenerated:
  420. continue
  421. current_value = variable_values.get(variable.name)
  422. if current_value not in (None, ""):
  423. continue
  424. length = getattr(variable, "autogenerated_length", 32)
  425. autogenerated_config = getattr(variable, "autogenerated_config", None)
  426. if getattr(variable, "autogenerated_base64", False):
  427. bytes_length = autogenerated_config.bytes_or_default() if autogenerated_config else length
  428. generated_value = base64.b64encode(secrets.token_bytes(bytes_length)).decode("utf-8")
  429. else:
  430. alphabet = (
  431. "".join(autogenerated_config.characters)
  432. if autogenerated_config and autogenerated_config.characters
  433. else string.ascii_letters + string.digits
  434. )
  435. generated_value = "".join(secrets.choice(alphabet) for _ in range(length))
  436. variable_values[variable.name] = generated_value
  437. def _sanitize_content(self, content: str) -> str:
  438. """Normalize rendered text output."""
  439. if not content:
  440. return content
  441. lines = [line.rstrip() for line in content.split("\n")]
  442. sanitized: list[str] = []
  443. previous_blank = False
  444. for line in lines:
  445. is_blank = not line
  446. if is_blank and previous_blank:
  447. continue
  448. sanitized.append(line)
  449. previous_blank = is_blank
  450. return "\n".join(sanitized).lstrip("\n").rstrip("\n") + "\n"
  451. def _handle_render_error(
  452. self,
  453. error: Exception,
  454. template_file: TemplateFile,
  455. available_vars: set[str],
  456. variable_values: dict[str, Any],
  457. debug: bool,
  458. ) -> None:
  459. """Convert Jinja errors into TemplateRenderError."""
  460. error_message, line_number, column, context_lines, suggestions = TemplateErrorHandler.parse_jinja_error(
  461. error,
  462. template_file,
  463. self.files_dir,
  464. available_vars,
  465. )
  466. context = RenderErrorContext(
  467. file_path=str(template_file.relative_path),
  468. line_number=line_number,
  469. column=column,
  470. context_lines=context_lines,
  471. variable_context={key: str(value) for key, value in variable_values.items()} if debug else {},
  472. suggestions=suggestions,
  473. original_error=error,
  474. )
  475. raise TemplateRenderError(message=error_message, context=context) from error
  476. def render(self, variables: VariableCollection, debug: bool = False) -> tuple[dict[str, str], dict[str, Any]]:
  477. """Render every file under files/ using the new delimiter set."""
  478. variable_values = variables.get_satisfied_values()
  479. self._generate_autogenerated_values(variables, variable_values)
  480. rendered_files: dict[str, str] = {}
  481. available_vars = set(variable_values.keys())
  482. for template_file in self.template_files:
  483. try:
  484. template = self.jinja_env.get_template(str(template_file.relative_path))
  485. rendered_content = template.render(**variable_values)
  486. rendered_content = self._sanitize_content(rendered_content)
  487. except (
  488. UndefinedError,
  489. Jinja2TemplateSyntaxError,
  490. Jinja2TemplateNotFound,
  491. Jinja2TemplateError,
  492. ) as exc:
  493. self._handle_render_error(exc, template_file, available_vars, variable_values, debug)
  494. except Exception as exc:
  495. raise TemplateRenderError(
  496. message=f"Unexpected rendering error: {exc}",
  497. context=RenderErrorContext(
  498. file_path=str(template_file.relative_path),
  499. original_error=exc,
  500. suggestions=["Check the template content and variable values."],
  501. ),
  502. ) from exc
  503. stripped = rendered_content.strip()
  504. if stripped and stripped != "---":
  505. rendered_files[str(template_file.output_path)] = rendered_content
  506. return rendered_files, variable_values
  507. @property
  508. def template_files(self) -> list[TemplateFile]:
  509. if self.__template_files is None:
  510. self._collect_template_files()
  511. return self.__template_files
  512. @property
  513. def jinja_env(self) -> Environment:
  514. if self.__jinja_env is None:
  515. self.__jinja_env = self._create_jinja_env(self.files_dir)
  516. return self.__jinja_env
  517. @property
  518. def used_variables(self) -> set[str]:
  519. if self.__used_variables is None:
  520. self.__used_variables = self._extract_all_used_variables()
  521. return self.__used_variables
  522. @property
  523. def variables(self) -> VariableCollection:
  524. if self.__variables is None:
  525. spec = self._normalize_manifest_variables()
  526. self._validate_variable_definitions(self.used_variables, spec)
  527. self.__variables = VariableCollection(spec)
  528. self.__variables.sort_sections()
  529. return self.__variables