template.py 27 KB

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