|
|
@@ -2,6 +2,7 @@ from .variables import Variable, VariableCollection
|
|
|
from pathlib import Path
|
|
|
from typing import Any, Dict, List, Set, Tuple, Optional
|
|
|
from dataclasses import dataclass, field
|
|
|
+from collections import OrderedDict
|
|
|
import logging
|
|
|
import re
|
|
|
from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
|
|
|
@@ -10,6 +11,15 @@ import frontmatter
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
+def _log_variable_stage(stage: str, names) -> None:
|
|
|
+ """Helper to emit consistent debug output for variable lists."""
|
|
|
+ if not names:
|
|
|
+ return
|
|
|
+ if isinstance(names, (set, tuple)):
|
|
|
+ names = list(names)
|
|
|
+ logger.debug(f"{stage}: {names}")
|
|
|
+
|
|
|
+
|
|
|
@dataclass
|
|
|
class Template:
|
|
|
"""Represents a template file with frontmatter and content."""
|
|
|
@@ -28,6 +38,8 @@ class Template:
|
|
|
module: str = ""
|
|
|
tags: List[str] = field(default_factory=list)
|
|
|
files: List[str] = field(default_factory=list)
|
|
|
+ library: str = ""
|
|
|
+ variable_sections: "OrderedDict[str, Dict[str, Any]]" = field(default_factory=OrderedDict, init=False)
|
|
|
|
|
|
# Extracted/merged variables
|
|
|
variables: VariableCollection = field(default_factory=VariableCollection, init=False)
|
|
|
@@ -41,7 +53,10 @@ class Template:
|
|
|
for name, value in variable_values.items():
|
|
|
var = self.variables.get_variable(name)
|
|
|
if var:
|
|
|
- var.value = value
|
|
|
+ try:
|
|
|
+ var.value = var.convert(value)
|
|
|
+ except ValueError as exc:
|
|
|
+ raise ValueError(f"Invalid value for variable '{name}': {exc}")
|
|
|
|
|
|
env = self._create_jinja_env()
|
|
|
context = self.variables.to_jinja_context()
|
|
|
@@ -53,7 +68,12 @@ class Template:
|
|
|
return self.variables.get_variable_names()
|
|
|
|
|
|
@classmethod
|
|
|
- def from_file(cls, file_path: Path, module_variables: Dict[str, Any] = None) -> "Template":
|
|
|
+ def from_file(
|
|
|
+ cls,
|
|
|
+ file_path: Path,
|
|
|
+ module_sections: Dict[str, Any] = None,
|
|
|
+ library_name: str = ""
|
|
|
+ ) -> "Template":
|
|
|
"""Create a Template instance from a file path."""
|
|
|
logger.debug(f"Loading template from file: {file_path}")
|
|
|
|
|
|
@@ -73,15 +93,41 @@ class Template:
|
|
|
module=frontmatter_data.get("module", ""),
|
|
|
tags=frontmatter_data.get("tags", []),
|
|
|
files=frontmatter_data.get("files", []),
|
|
|
+ library=library_name,
|
|
|
)
|
|
|
|
|
|
logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'})")
|
|
|
|
|
|
+ module_section_defs = module_sections or {}
|
|
|
+ module_flat, module_section_meta = cls._flatten_sections(module_section_defs)
|
|
|
+
|
|
|
+ template_section_defs = frontmatter_data.get("variable_sections") or {}
|
|
|
+ legacy_frontmatter_vars = frontmatter_data.get("variables")
|
|
|
+ if legacy_frontmatter_vars:
|
|
|
+ template_section_defs = OrderedDict(template_section_defs)
|
|
|
+ template_section_defs["template_specific"] = {
|
|
|
+ "title": f"{template.name or template_id} Specific",
|
|
|
+ "prefix": "",
|
|
|
+ "vars": legacy_frontmatter_vars,
|
|
|
+ }
|
|
|
+
|
|
|
+ template_flat, template_section_meta = cls._flatten_sections(template_section_defs)
|
|
|
+
|
|
|
# Extract and merge variables (only those actually used)
|
|
|
- variables, tpl_names, mod_names = cls._merge_variables(content, frontmatter_data, module_variables or {})
|
|
|
+ variables, tpl_names, mod_names = cls._merge_variables(
|
|
|
+ content,
|
|
|
+ module_flat,
|
|
|
+ template_flat,
|
|
|
+ template_id,
|
|
|
+ )
|
|
|
template.variables = variables
|
|
|
template.template_var_names = tpl_names
|
|
|
template.module_var_names = mod_names
|
|
|
+ template.variable_sections = cls._combine_sections_meta(
|
|
|
+ module_section_meta,
|
|
|
+ template_section_meta,
|
|
|
+ template.variables,
|
|
|
+ )
|
|
|
|
|
|
logger.debug(
|
|
|
f"Final variables for template '{template.id}': {template.variables.get_variable_names()}"
|
|
|
@@ -113,45 +159,6 @@ class Template:
|
|
|
post = frontmatter.load(f)
|
|
|
return post.metadata, post.content
|
|
|
|
|
|
- @staticmethod
|
|
|
- def _extract_variables_from_frontmatter(frontmatter_data: Dict[str, Any]) -> Dict[str, Variable]:
|
|
|
- """Extract variables from the 'variables:' section in frontmatter as Variable objects.
|
|
|
-
|
|
|
- Example:
|
|
|
- variables:
|
|
|
- var_name:
|
|
|
- description: "..."
|
|
|
- type: "str"
|
|
|
- """
|
|
|
- variables_data = frontmatter_data.get("variables")
|
|
|
- result: Dict[str, Variable] = {}
|
|
|
-
|
|
|
- if not variables_data:
|
|
|
- return result
|
|
|
-
|
|
|
- try:
|
|
|
- if isinstance(variables_data, dict):
|
|
|
- for name, var_config in variables_data.items():
|
|
|
- if isinstance(var_config, dict):
|
|
|
- variable = Variable.from_dict(name, var_config)
|
|
|
- result[name] = variable
|
|
|
- else:
|
|
|
- logger.warning(
|
|
|
- f"Invalid variable configuration for '{name}': expected dict, got {type(var_config).__name__}"
|
|
|
- )
|
|
|
- else:
|
|
|
- raise ValueError(
|
|
|
- "Variables must be a dictionary. Use format: variables: { var_name: { type: 'str' } }"
|
|
|
- )
|
|
|
- except Exception as e:
|
|
|
- logger.error(f"Error parsing variables from frontmatter: {str(e)}")
|
|
|
- return {}
|
|
|
-
|
|
|
- logger.debug(
|
|
|
- f"Extracted {len(result)} variables (insertion order preserved): {list(result.keys())}"
|
|
|
- )
|
|
|
- return result
|
|
|
-
|
|
|
@staticmethod
|
|
|
def _extract_template_variables(content: str) -> Set[str]:
|
|
|
"""Extract variable names used in Jinja2 template content (flat names only).
|
|
|
@@ -192,8 +199,9 @@ class Template:
|
|
|
@staticmethod
|
|
|
def _merge_variables(
|
|
|
content: str,
|
|
|
- frontmatter_data: Dict[str, Any],
|
|
|
module_variables: Dict[str, Any],
|
|
|
+ template_variables: Dict[str, Any],
|
|
|
+ template_id: str,
|
|
|
) -> Tuple[VariableCollection, List[str], List[str]]:
|
|
|
"""Merge module + frontmatter vars, auto-create missing, and apply Jinja defaults.
|
|
|
|
|
|
@@ -206,60 +214,158 @@ class Template:
|
|
|
used_variables = Template._extract_template_variables(content)
|
|
|
jinja_defaults = Template._extract_jinja_defaults(content)
|
|
|
|
|
|
- if not used_variables:
|
|
|
- logger.debug("No variables found in template content")
|
|
|
- return VariableCollection()
|
|
|
+ declared_variables = set(module_variables.keys()) | set(template_variables.keys())
|
|
|
+ missing_declared = used_variables - declared_variables
|
|
|
+ if missing_declared:
|
|
|
+ raise ValueError(
|
|
|
+ "Unknown variables referenced in template: "
|
|
|
+ + ", ".join(sorted(missing_declared))
|
|
|
+ )
|
|
|
|
|
|
variables = VariableCollection()
|
|
|
|
|
|
- logger.debug(
|
|
|
- f"Processing module variables: {list(module_variables.keys()) if module_variables else []}"
|
|
|
+ # Keep only variables that are actually referenced in the template content,
|
|
|
+ # plus any explicitly defined in template frontmatter.
|
|
|
+ relevant_names = used_variables | set(template_variables.keys())
|
|
|
+
|
|
|
+ _log_variable_stage(
|
|
|
+ "Processing module variables",
|
|
|
+ list(module_variables.keys()) if module_variables else [],
|
|
|
)
|
|
|
|
|
|
- # Compatibility bridge: if module defines *_enabled toggles and legacy roots are used
|
|
|
- # (e.g., 'traefik' in template), ensure '<root>_enabled' is also included and map defaults.
|
|
|
- toggle_roots = {k[:-len('_enabled')] for k in module_variables.keys() if k.endswith('_enabled')}
|
|
|
-
|
|
|
- # Add missing toggles for used legacy roots
|
|
|
- bridged_used = set(used_variables)
|
|
|
- for root in toggle_roots:
|
|
|
- if root in used_variables:
|
|
|
- bridged_used.add(f"{root}_enabled")
|
|
|
-
|
|
|
- # Map Jinja defaults from legacy roots to *_enabled toggles
|
|
|
- bridged_defaults = dict(jinja_defaults)
|
|
|
- for root in toggle_roots:
|
|
|
- if root in jinja_defaults and f"{root}_enabled" not in bridged_defaults:
|
|
|
- bridged_defaults[f"{root}_enabled"] = jinja_defaults[root]
|
|
|
-
|
|
|
- # 1) Module variables (lowest precedence)
|
|
|
- variables.add_from_dict(module_variables, bridged_used, label="module")
|
|
|
-
|
|
|
- # 2) Frontmatter variables (override module specs)
|
|
|
- template_vars = Template._extract_variables_from_frontmatter(frontmatter_data)
|
|
|
- variables.add_from_dict(template_vars, bridged_used, label="template")
|
|
|
-
|
|
|
- # Track source ordering lists
|
|
|
- template_var_names_ordered: List[str] = [n for n in template_vars.keys() if n in bridged_used]
|
|
|
- module_var_names_ordered: List[str] = [n for n in module_variables.keys() if n in bridged_used]
|
|
|
-
|
|
|
- # 3) Auto-create missing variables for anything used in the template
|
|
|
- defined_names = set(variables.variables.keys())
|
|
|
- missing = bridged_used - defined_names
|
|
|
-
|
|
|
- # Auto-create missing variables (flat names only). Skip legacy roots if their *_enabled exists.
|
|
|
- for name in sorted(missing):
|
|
|
- if name in toggle_roots:
|
|
|
- # Will be provided via alias from '<root>_enabled'
|
|
|
- logger.debug(f"Skipping auto-create for legacy root '{name}' (alias provided by *_enabled)")
|
|
|
- continue
|
|
|
- variables.variables[name] = Variable(name=name, type="str")
|
|
|
- logger.debug(f"Auto-created variable '{name}' (flat)")
|
|
|
+ variables.add_from_dict(module_variables, relevant_names, label="module")
|
|
|
+ variables.add_from_dict(template_variables, relevant_names, label="template")
|
|
|
+
|
|
|
+ template_var_names_ordered: List[str] = [n for n in template_variables.keys() if n in relevant_names]
|
|
|
+ module_var_names_ordered: List[str] = [n for n in module_variables.keys() if n in relevant_names]
|
|
|
+
|
|
|
+ variables.apply_jinja_defaults(jinja_defaults)
|
|
|
|
|
|
- # Apply Jinja defaults last (only fill if still empty)
|
|
|
- variables.apply_jinja_defaults(bridged_defaults)
|
|
|
+ Template._ensure_defaults(variables, template_id)
|
|
|
|
|
|
logger.debug(
|
|
|
- f"Smart merge: {len(bridged_used)} used, {len(variables)} defined = {len(variables)} final variables"
|
|
|
+ f"Smart merge: {len(relevant_names)} used, {len(variables)} defined = {len(variables)} final variables"
|
|
|
)
|
|
|
return variables, template_var_names_ordered, module_var_names_ordered
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _ensure_defaults(variables: VariableCollection, template_id: str) -> None:
|
|
|
+ """Ensure every variable has a default value; raise if any are missing."""
|
|
|
+ missing: List[str] = []
|
|
|
+
|
|
|
+ for var_name in variables.get_variable_names():
|
|
|
+ variable = variables.get_variable(var_name)
|
|
|
+ if not variable:
|
|
|
+ continue
|
|
|
+ if variable.value not in (None, ""):
|
|
|
+ continue
|
|
|
+
|
|
|
+ missing.append(var_name)
|
|
|
+
|
|
|
+ if missing:
|
|
|
+ raise ValueError(
|
|
|
+ f"Missing default value(s) for variables {', '.join(missing)} in template '{template_id}'"
|
|
|
+ )
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _flatten_sections(
|
|
|
+ section_defs: Dict[str, Any],
|
|
|
+ ) -> Tuple[Dict[str, Dict[str, Any]], "OrderedDict[str, Dict[str, Any]]"]:
|
|
|
+ flat: Dict[str, Dict[str, Any]] = {}
|
|
|
+ meta: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
|
+
|
|
|
+ if not section_defs:
|
|
|
+ return flat, meta
|
|
|
+
|
|
|
+ for key, data in section_defs.items():
|
|
|
+ if not isinstance(data, dict):
|
|
|
+ continue
|
|
|
+
|
|
|
+ title = data.get("title") or key.replace('_', ' ').title()
|
|
|
+ toggle_name = data.get("toggle")
|
|
|
+ vars_spec = data.get("vars") or {}
|
|
|
+
|
|
|
+ variables_list: List[str] = []
|
|
|
+ for var_name, spec in vars_spec.items():
|
|
|
+ spec = dict(spec)
|
|
|
+ spec.setdefault("section", title)
|
|
|
+ flat[var_name] = spec
|
|
|
+ variables_list.append(var_name)
|
|
|
+
|
|
|
+ if toggle_name:
|
|
|
+ if toggle_name not in flat:
|
|
|
+ flat[toggle_name] = {
|
|
|
+ "type": "bool",
|
|
|
+ "default": False,
|
|
|
+ "section": title,
|
|
|
+ "description": data.get("toggle_description", ""),
|
|
|
+ }
|
|
|
+ if toggle_name not in variables_list:
|
|
|
+ variables_list.insert(0, toggle_name)
|
|
|
+
|
|
|
+ meta[key] = {
|
|
|
+ "title": title,
|
|
|
+ "prompt": data.get("prompt"),
|
|
|
+ "description": data.get("description"),
|
|
|
+ "toggle": toggle_name,
|
|
|
+ "variables": variables_list,
|
|
|
+ }
|
|
|
+
|
|
|
+ return flat, meta
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _combine_sections_meta(
|
|
|
+ module_meta: "OrderedDict[str, Dict[str, Any]]",
|
|
|
+ template_meta: "OrderedDict[str, Dict[str, Any]]",
|
|
|
+ variables: VariableCollection,
|
|
|
+ ) -> "OrderedDict[str, Dict[str, Any]]":
|
|
|
+ combined: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
|
+
|
|
|
+ def _add_meta(source: "OrderedDict[str, Dict[str, Any]]") -> None:
|
|
|
+ for key, meta in source.items():
|
|
|
+ existing = combined.get(key)
|
|
|
+ if existing:
|
|
|
+ existing["variables"].extend(v for v in meta["variables"] if v not in existing["variables"])
|
|
|
+ if meta.get("prompt"):
|
|
|
+ existing["prompt"] = meta["prompt"]
|
|
|
+ if meta.get("description"):
|
|
|
+ existing["description"] = meta["description"]
|
|
|
+ if meta.get("toggle"):
|
|
|
+ existing["toggle"] = meta["toggle"]
|
|
|
+ if meta.get("title"):
|
|
|
+ existing["title"] = meta["title"]
|
|
|
+ else:
|
|
|
+ combined[key] = {
|
|
|
+ "title": meta.get("title") or key.replace('_', ' ').title(),
|
|
|
+ "prompt": meta.get("prompt"),
|
|
|
+ "description": meta.get("description"),
|
|
|
+ "toggle": meta.get("toggle"),
|
|
|
+ "variables": list(meta.get("variables", [])),
|
|
|
+ }
|
|
|
+
|
|
|
+ _add_meta(module_meta)
|
|
|
+ _add_meta(template_meta)
|
|
|
+
|
|
|
+ # Filter out variables that are not present in the final collection
|
|
|
+ existing_names = set(variables.get_variable_names())
|
|
|
+ seen: Set[str] = set()
|
|
|
+ for key, meta in list(combined.items()):
|
|
|
+ filtered = [name for name in meta["variables"] if name in existing_names]
|
|
|
+ if not filtered:
|
|
|
+ del combined[key]
|
|
|
+ continue
|
|
|
+ meta["variables"] = filtered
|
|
|
+ seen.update(filtered)
|
|
|
+
|
|
|
+ # Add remaining variables that were not covered by sections
|
|
|
+ remaining = [name for name in existing_names if name not in seen]
|
|
|
+ if remaining:
|
|
|
+ combined["other"] = {
|
|
|
+ "title": "Other",
|
|
|
+ "prompt": None,
|
|
|
+ "description": None,
|
|
|
+ "toggle": None,
|
|
|
+ "variables": remaining,
|
|
|
+ }
|
|
|
+
|
|
|
+ return combined
|