Deterministic templating and generation support
This commit is contained in:
parent
4f010315bb
commit
1a1b5ab39c
13 changed files with 1122 additions and 7 deletions
19
src/markitect_tool/template/__init__.py
Normal file
19
src/markitect_tool/template/__init__.py
Normal 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",
|
||||
]
|
||||
179
src/markitect_tool/template/engine.py
Normal file
179
src/markitect_tool/template/engine.py
Normal 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue