Deterministic templating and generation support

This commit is contained in:
tegwick 2026-05-04 01:12:54 +02:00
parent 4f010315bb
commit 1a1b5ab39c
13 changed files with 1122 additions and 7 deletions

View file

@ -0,0 +1,19 @@
"""Deterministic Markdown template rendering."""
from markitect_tool.template.engine import (
MissingTemplateVariable,
TemplateAnalysis,
TemplateError,
TemplateRenderResult,
analyze_template,
render_template,
)
__all__ = [
"MissingTemplateVariable",
"TemplateAnalysis",
"TemplateError",
"TemplateRenderResult",
"analyze_template",
"render_template",
]

View file

@ -0,0 +1,179 @@
"""Small deterministic template engine for Markdown generation."""
from __future__ import annotations
import re
from dataclasses import asdict, dataclass
from typing import Any
import yaml
class TemplateError(ValueError):
"""Raised when a template cannot be parsed or rendered."""
class MissingTemplateVariable(TemplateError):
"""Raised when strict rendering cannot resolve a variable."""
_IDENT = r"(?:_|[^\W\d])\w*"
_VARIABLE_RE = re.compile(r"\{\{\s*(?P<name>" + _IDENT + r"(?:\." + _IDENT + r")*)\s*\}\}", re.UNICODE)
_BRACE_RE = re.compile(r"\{\{(?P<body>.*?)\}\}", re.DOTALL)
@dataclass(frozen=True)
class TemplateAnalysis:
"""Variables and syntax diagnostics for one template."""
variables: list[str]
root_variables: list[str]
nested_variables: list[str]
syntax_errors: list[str]
max_nesting_depth: int = 0
@property
def total_variables(self) -> int:
return len(self.variables)
@property
def unique_variables(self) -> int:
return len(set(self.variables))
@property
def valid(self) -> bool:
return not self.syntax_errors
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["total_variables"] = self.total_variables
data["unique_variables"] = self.unique_variables
data["valid"] = self.valid
return data
@dataclass(frozen=True)
class TemplateRenderResult:
"""Rendered Markdown plus trace information."""
markdown: str
variables: list[str]
missing_variables: list[str]
strict: bool = True
@property
def complete(self) -> bool:
return not self.missing_variables
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["complete"] = self.complete
return data
def analyze_template(template_text: str) -> TemplateAnalysis:
"""Analyze variable usage and syntax in a template."""
variables = _unique(_VARIABLE_RE.findall(template_text))
roots = _unique(variable.split(".", 1)[0] for variable in variables)
nested = [variable for variable in variables if "." in variable]
max_depth = max((len(variable.split(".")) for variable in variables), default=0)
return TemplateAnalysis(
variables=variables,
root_variables=roots,
nested_variables=nested,
syntax_errors=_syntax_errors(template_text),
max_nesting_depth=max_depth,
)
def render_template(
template_text: str,
data: dict[str, Any],
*,
strict: bool = True,
) -> TemplateRenderResult:
"""Render ``{{variable.path}}`` placeholders with data."""
if not isinstance(data, dict):
raise TypeError("Template data must be a mapping")
analysis = analyze_template(template_text)
if analysis.syntax_errors:
raise TemplateError("; ".join(analysis.syntax_errors))
missing: list[str] = []
def replace(match: re.Match[str]) -> str:
variable = match.group("name")
value = _resolve_path(data, variable)
if value is _MISSING:
missing.append(variable)
if strict:
raise MissingTemplateVariable(f"Missing template variable `{variable}`")
return match.group(0)
return _format_value(value)
markdown = _VARIABLE_RE.sub(replace, template_text)
return TemplateRenderResult(
markdown=markdown,
variables=analysis.variables,
missing_variables=_unique(missing),
strict=strict,
)
def _syntax_errors(template_text: str) -> list[str]:
errors: list[str] = []
opens = template_text.count("{{")
closes = template_text.count("}}")
if opens != closes:
errors.append(f"Unmatched template braces: {opens} opening, {closes} closing")
for match in _BRACE_RE.finditer(template_text):
raw = match.group(0)
if not _VARIABLE_RE.fullmatch(raw):
errors.append(f"Invalid template variable syntax: {raw}")
return errors
_MISSING = object()
def _resolve_path(data: dict[str, Any], path: str) -> Any:
current: Any = data
for part in path.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return _MISSING
return current
def _format_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int | float):
return str(value)
if isinstance(value, list):
if not value:
return ""
return "\n".join(f"- {_format_scalar(item)}" for item in value)
if isinstance(value, dict):
return yaml.safe_dump(value, sort_keys=False).strip()
return str(value)
def _format_scalar(value: Any) -> str:
if isinstance(value, str):
return value
if isinstance(value, int | float | bool) or value is None:
return _format_value(value)
return yaml.safe_dump(value, sort_keys=False).strip()
def _unique(items) -> list:
return list(dict.fromkeys(items))