template.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  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. guide: str = ""
  168. icon: dict[str, Any] = field(default_factory=dict)
  169. def __init__(
  170. self,
  171. template_data: dict[str, Any],
  172. library_name: str | None = None,
  173. library_type: str = "git",
  174. ) -> None:
  175. metadata = template_data.get("metadata")
  176. if not isinstance(metadata, dict):
  177. raise TemplateValidationError("Template format error: missing 'metadata' object in template.json")
  178. self.name = str(metadata.get("name", "")).strip()
  179. self.description = str(metadata.get("description", "")).rstrip("\n")
  180. self.author = str(metadata.get("author", "")).strip()
  181. self.date = str(metadata.get("date", "")).strip()
  182. self.version = TemplateVersionMetadata.from_metadata(metadata)
  183. self.module = str(template_data.get("kind", "")).strip()
  184. self.tags = metadata.get("tags", []) if isinstance(metadata.get("tags", []), list) else []
  185. self.library = library_name or "unknown"
  186. self.library_type = library_type
  187. self.draft = bool(metadata.get("draft", False))
  188. self.guide = str(metadata.get("guide", "")).rstrip("\n")
  189. self.icon = metadata.get("icon", {}) if isinstance(metadata.get("icon"), dict) else {}
  190. class Template:
  191. """Loads, validates, and renders template.json-based templates."""
  192. def __init__(self, template_dir: Path, library_name: str, library_type: str = "git") -> None:
  193. self.template_dir = template_dir
  194. self.directory_id = template_dir.name
  195. self.id = template_dir.name
  196. self.original_id = template_dir.name
  197. self.library_name = library_name
  198. self.library_type = library_type
  199. self.__jinja_env: Environment | None = None
  200. self.__used_variables: set[str] | None = None
  201. self.__variables: VariableCollection | None = None
  202. self.__template_files: list[TemplateFile] | None = None
  203. try:
  204. manifest_path = self._find_manifest_file()
  205. with manifest_path.open(encoding="utf-8") as file_handle:
  206. self._template_data = json.load(file_handle)
  207. if not isinstance(self._template_data, dict):
  208. raise TemplateValidationError("Template format error: template.json must contain a JSON object")
  209. self.metadata = TemplateMetadata(self._template_data, library_name, library_type)
  210. self._validate_kind(self._template_data)
  211. self.slug = self._get_template_slug(self._template_data, self.directory_id)
  212. self.id = self.slug
  213. self.original_id = self.slug
  214. self.files_dir = self.template_dir / TEMPLATE_FILES_DIRNAME
  215. if not self.files_dir.is_dir():
  216. raise TemplateValidationError(
  217. f"Template '{self.id}' is missing required '{TEMPLATE_FILES_DIRNAME}/' directory"
  218. )
  219. self._validate_template_manifest()
  220. logger.info("Loaded template '%s' (version=%s)", self.id, self.metadata.version or "unknown")
  221. except (json.JSONDecodeError, TemplateValidationError, FileNotFoundError) as exc:
  222. logger.error("Error loading template from %s: %s", template_dir, exc)
  223. raise TemplateLoadError(f"Error loading template from {template_dir}: {exc}") from exc
  224. except OSError as exc:
  225. logger.error("File I/O error loading template %s: %s", template_dir, exc)
  226. raise TemplateLoadError(f"File I/O error loading template from {template_dir}: {exc}") from exc
  227. def set_qualified_id(self, library_name: str | None = None) -> None:
  228. """Set a qualified template ID when duplicates exist across libraries."""
  229. lib_name = library_name or self.library_name
  230. self.id = f"{self.original_id}.{lib_name}"
  231. def _find_manifest_file(self) -> Path:
  232. """Locate template.json and reject legacy template manifests."""
  233. manifest_path = self.template_dir / TEMPLATE_MANIFEST_FILENAME
  234. if manifest_path.exists():
  235. return manifest_path
  236. for legacy_name in LEGACY_TEMPLATE_FILENAMES:
  237. legacy_path = self.template_dir / legacy_name
  238. if legacy_path.exists():
  239. raise TemplateValidationError(
  240. "Legacy template manifests are incompatible with boilerplates 0.2.0. "
  241. f"Replace '{legacy_name}' with '{TEMPLATE_MANIFEST_FILENAME}' and move renderable files into "
  242. f"'{TEMPLATE_FILES_DIRNAME}/'."
  243. )
  244. raise FileNotFoundError(f"Main template file ({TEMPLATE_MANIFEST_FILENAME}) not found in {self.template_dir}")
  245. def _validate_template_manifest(self) -> None:
  246. """Validate required top-level manifest structure."""
  247. variables = self._template_data.get("variables", [])
  248. if not isinstance(variables, list):
  249. raise TemplateValidationError("Template format error: 'variables' must be a list")
  250. @staticmethod
  251. def _validate_kind(template_data: dict[str, Any]) -> None:
  252. """Validate presence of the template kind."""
  253. if not template_data.get("kind"):
  254. raise TemplateValidationError("Template format error: missing 'kind' field")
  255. @staticmethod
  256. def _get_template_slug(template_data: dict[str, Any], fallback: str) -> str:
  257. """Resolve the canonical template ID from the manifest slug."""
  258. manifest_slug = str(template_data.get("slug", "")).strip()
  259. kind = str(template_data.get("kind", "")).strip()
  260. if not manifest_slug:
  261. return fallback
  262. return normalize_template_slug(manifest_slug, kind)
  263. @staticmethod
  264. def _create_jinja_env(search_path: Path) -> SandboxedEnvironment:
  265. """Create the custom-delimiter Jinja environment for template rendering."""
  266. return SandboxedEnvironment(
  267. loader=FileSystemLoader(search_path),
  268. autoescape=False,
  269. variable_start_string=VARIABLE_START,
  270. variable_end_string=VARIABLE_END,
  271. block_start_string=BLOCK_START,
  272. block_end_string=BLOCK_END,
  273. comment_start_string=COMMENT_START,
  274. comment_end_string=COMMENT_END,
  275. keep_trailing_newline=True,
  276. trim_blocks=False,
  277. lstrip_blocks=False,
  278. )
  279. def _collect_template_files(self) -> None:
  280. """Collect every renderable file under files/."""
  281. template_files: list[TemplateFile] = []
  282. for root, _, files in os.walk(self.files_dir):
  283. for filename in files:
  284. absolute_path = Path(root) / filename
  285. relative_path = absolute_path.relative_to(self.files_dir)
  286. template_files.append(
  287. TemplateFile(
  288. relative_path=relative_path,
  289. output_path=relative_path,
  290. )
  291. )
  292. template_files.sort(key=lambda item: str(item.relative_path))
  293. self.__template_files = template_files
  294. def _validate_delimiters(self) -> None:
  295. """Reject legacy Jinja delimiters in 0.2.0 templates."""
  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. except OSError as exc:
  301. raise TemplateValidationError(
  302. f"Failed to read template file '{template_file.relative_path}': {exc}"
  303. ) from exc
  304. for delimiter in LEGACY_JINJA_DELIMITERS:
  305. if delimiter in content:
  306. raise TemplateValidationError(
  307. f"Legacy Jinja delimiter '{delimiter}' found in '{template_file.relative_path}'. "
  308. f"Use {VARIABLE_START} {VARIABLE_END} for variables, "
  309. f"{BLOCK_START} {BLOCK_END} for blocks, and "
  310. f"{COMMENT_START} {COMMENT_END} for comments."
  311. )
  312. def _extract_all_used_variables(self) -> set[str]:
  313. """Extract undeclared variables from all files under files/."""
  314. used_variables: set[str] = set()
  315. syntax_errors = []
  316. self._variable_usage_map: dict[str, list[str]] = {}
  317. self._validate_delimiters()
  318. for template_file in self.template_files:
  319. file_path = self.files_dir / template_file.relative_path
  320. try:
  321. content = file_path.read_text(encoding="utf-8")
  322. ast = self.jinja_env.parse(content)
  323. file_variables = meta.find_undeclared_variables(ast)
  324. used_variables.update(file_variables)
  325. for variable_name in file_variables:
  326. self._variable_usage_map.setdefault(variable_name, []).append(str(template_file.relative_path))
  327. except Jinja2TemplateSyntaxError as exc:
  328. syntax_errors.append(f"{template_file.relative_path}:{exc.lineno}: {exc.message}")
  329. except OSError as exc:
  330. raise TemplateValidationError(
  331. f"Failed to read template file '{template_file.relative_path}': {exc}"
  332. ) from exc
  333. if syntax_errors:
  334. raise TemplateValidationError("Template syntax validation failed:\n" + "\n".join(sorted(syntax_errors)))
  335. return used_variables
  336. @staticmethod
  337. def _merge_item_config(item_data: dict[str, Any]) -> dict[str, Any]:
  338. """Flatten manifest item fields into the VariableCollection runtime shape."""
  339. if not isinstance(item_data, dict):
  340. raise TemplateValidationError("Variable items must be objects")
  341. if "name" not in item_data:
  342. raise TemplateValidationError("Variable item missing required 'name' field")
  343. item_type = item_data.get("type", "str")
  344. item_config = item_data.get("config", {})
  345. if item_config is not None and not isinstance(item_config, dict):
  346. raise TemplateValidationError(f"Variable '{item_data['name']}' config must be an object")
  347. normalized = {"type": item_type}
  348. field_map = {
  349. "default": "default",
  350. "value": "value",
  351. "required": "required",
  352. "needs": "needs",
  353. "extra": "extra",
  354. }
  355. for source_key, target_key in field_map.items():
  356. if source_key in item_data:
  357. normalized[target_key] = item_data[source_key]
  358. description = item_data.get("description") or item_data.get("title")
  359. if description is not None:
  360. normalized["description"] = description
  361. if "title" in item_data:
  362. normalized["prompt"] = item_data["title"]
  363. config_value = item_data.get("config", item_config)
  364. if config_value:
  365. normalized["config"] = config_value
  366. return normalized
  367. def _normalize_manifest_variables(self) -> dict[str, Any]:
  368. """Convert variables[].items manifest structure into VariableCollection format."""
  369. spec: dict[str, Any] = {}
  370. for group_data in self._template_data.get("variables", []):
  371. if not isinstance(group_data, dict):
  372. raise TemplateValidationError("Variable groups must be objects")
  373. if "name" not in group_data:
  374. raise TemplateValidationError("Variable group missing required 'name' field")
  375. if "title" not in group_data:
  376. raise TemplateValidationError(f"Variable group '{group_data['name']}' missing required 'title' field")
  377. group_name = group_data["name"]
  378. items = group_data.get("items")
  379. if not isinstance(items, list):
  380. raise TemplateValidationError(f"Variable group '{group_name}' must define an 'items' array")
  381. section_data: dict[str, Any] = {
  382. "title": group_data["title"],
  383. "vars": {},
  384. }
  385. for optional_key in ("description", "toggle", "needs"):
  386. if optional_key in group_data:
  387. section_data[optional_key] = group_data[optional_key]
  388. for item_data in items:
  389. normalized_item = self._merge_item_config(item_data)
  390. variable_name = item_data["name"]
  391. section_data["vars"][variable_name] = normalized_item
  392. spec[group_name] = section_data
  393. return spec
  394. def _validate_variable_definitions(self, used_variables: set[str], spec: dict[str, Any]) -> None:
  395. """Validate that all rendered variables are declared in the manifest."""
  396. defined_variables = set()
  397. for section_data in spec.values():
  398. defined_variables.update((section_data.get("vars") or {}).keys())
  399. undefined_variables = used_variables - defined_variables
  400. if not undefined_variables:
  401. return
  402. undefined_list = sorted(undefined_variables)
  403. file_locations = []
  404. for variable_name in undefined_list:
  405. if variable_name in getattr(self, "_variable_usage_map", {}):
  406. locations = ", ".join(self._variable_usage_map[variable_name])
  407. file_locations.append(f" - {variable_name}: {locations}")
  408. error_lines = [
  409. f"Template validation error in '{self.id}': variables used in files/ but not declared in template.json."
  410. ]
  411. if file_locations:
  412. error_lines.extend(file_locations)
  413. else:
  414. error_lines.append(", ".join(undefined_list))
  415. error_lines.extend(
  416. [
  417. "",
  418. "Declare missing variables under variables[].items in template.json.",
  419. "Example:",
  420. "{",
  421. ' "variables": [',
  422. " {",
  423. ' "name": "general",',
  424. ' "title": "General",',
  425. ' "items": [',
  426. ' { "name": "missing_var", "type": "str", "title": "Missing var" }',
  427. " ]",
  428. " }",
  429. " ]",
  430. "}",
  431. ]
  432. )
  433. raise TemplateValidationError("\n".join(error_lines))
  434. def _generate_autogenerated_values(
  435. self,
  436. variables: VariableCollection,
  437. variable_values: dict[str, Any],
  438. ) -> None:
  439. """Populate autogenerated values for empty variables."""
  440. for variable in variables._variable_map.values():
  441. if not variable.autogenerated:
  442. continue
  443. current_value = variable_values.get(variable.name)
  444. if current_value not in (None, ""):
  445. continue
  446. length = getattr(variable, "autogenerated_length", 32)
  447. autogenerated_config = getattr(variable, "autogenerated_config", None)
  448. if getattr(variable, "autogenerated_base64", False):
  449. bytes_length = autogenerated_config.bytes_or_default() if autogenerated_config else length
  450. generated_value = base64.b64encode(secrets.token_bytes(bytes_length)).decode("utf-8")
  451. else:
  452. alphabet = (
  453. "".join(autogenerated_config.characters)
  454. if autogenerated_config and autogenerated_config.characters
  455. else string.ascii_letters + string.digits
  456. )
  457. generated_value = "".join(secrets.choice(alphabet) for _ in range(length))
  458. variable_values[variable.name] = generated_value
  459. def _sanitize_content(self, content: str) -> str:
  460. """Normalize rendered text output."""
  461. if not content:
  462. return content
  463. lines = [line.rstrip() for line in content.split("\n")]
  464. sanitized: list[str] = []
  465. previous_blank = False
  466. for line in lines:
  467. is_blank = not line
  468. if is_blank and previous_blank:
  469. continue
  470. sanitized.append(line)
  471. previous_blank = is_blank
  472. return "\n".join(sanitized).lstrip("\n").rstrip("\n") + "\n"
  473. def _handle_render_error(
  474. self,
  475. error: Exception,
  476. template_file: TemplateFile,
  477. available_vars: set[str],
  478. variable_values: dict[str, Any],
  479. debug: bool,
  480. ) -> None:
  481. """Convert Jinja errors into TemplateRenderError."""
  482. error_message, line_number, column, context_lines, suggestions = TemplateErrorHandler.parse_jinja_error(
  483. error,
  484. template_file,
  485. self.files_dir,
  486. available_vars,
  487. )
  488. context = RenderErrorContext(
  489. file_path=str(template_file.relative_path),
  490. line_number=line_number,
  491. column=column,
  492. context_lines=context_lines,
  493. variable_context={key: str(value) for key, value in variable_values.items()} if debug else {},
  494. suggestions=suggestions,
  495. original_error=error,
  496. )
  497. raise TemplateRenderError(message=error_message, context=context) from error
  498. def render(self, variables: VariableCollection, debug: bool = False) -> tuple[dict[str, str], dict[str, Any]]:
  499. """Render every file under files/ using the new delimiter set."""
  500. variable_values = variables.get_satisfied_values()
  501. self._generate_autogenerated_values(variables, variable_values)
  502. rendered_files: dict[str, str] = {}
  503. available_vars = set(variable_values.keys())
  504. for template_file in self.template_files:
  505. try:
  506. template = self.jinja_env.get_template(str(template_file.relative_path))
  507. rendered_content = template.render(**variable_values)
  508. rendered_content = self._sanitize_content(rendered_content)
  509. except (
  510. UndefinedError,
  511. Jinja2TemplateSyntaxError,
  512. Jinja2TemplateNotFound,
  513. Jinja2TemplateError,
  514. ) as exc:
  515. self._handle_render_error(exc, template_file, available_vars, variable_values, debug)
  516. except Exception as exc:
  517. raise TemplateRenderError(
  518. message=f"Unexpected rendering error: {exc}",
  519. context=RenderErrorContext(
  520. file_path=str(template_file.relative_path),
  521. original_error=exc,
  522. suggestions=["Check the template content and variable values."],
  523. ),
  524. ) from exc
  525. stripped = rendered_content.strip()
  526. if stripped and stripped != "---":
  527. rendered_files[str(template_file.output_path)] = rendered_content
  528. return rendered_files, variable_values
  529. @property
  530. def template_files(self) -> list[TemplateFile]:
  531. if self.__template_files is None:
  532. self._collect_template_files()
  533. return self.__template_files
  534. @property
  535. def jinja_env(self) -> Environment:
  536. if self.__jinja_env is None:
  537. self.__jinja_env = self._create_jinja_env(self.files_dir)
  538. return self.__jinja_env
  539. @property
  540. def used_variables(self) -> set[str]:
  541. if self.__used_variables is None:
  542. self.__used_variables = self._extract_all_used_variables()
  543. return self.__used_variables
  544. @property
  545. def variables(self) -> VariableCollection:
  546. if self.__variables is None:
  547. spec = self._normalize_manifest_variables()
  548. self._validate_variable_definitions(self.used_variables, spec)
  549. self.__variables = VariableCollection(spec)
  550. self.__variables.sort_sections()
  551. return self.__variables