1"""Prompt rendering engine — f-string (default) with optional Jinja2 support."""
2
3from __future__ import annotations
4
5from enum import StrEnum
6import re
7from string import Template
8from typing import Any
9
10from lexigram.ai.prompt.exceptions import PromptRenderError
11
12
13class RenderFormat(StrEnum):
14 """Supported template substitution formats."""
15
16 F_STRING = "f_string"
17 """``{variable_name}`` style (default)."""
18 JINJA2 = "jinja2"
19 """Jinja2 ``{{ variable_name }}`` style (requires ``jinja2`` extra)."""
20 DOLLAR = "dollar"
21 """``$variable_name`` / ``${variable_name}`` style (stdlib ``string.Template``)."""
22 SIMPLE = "simple"
23 """Literal template — no substitution performed."""
24
25
26class PromptRenderer:
27 """Renders a template string by substituting named variables.
28
29 Supports three formats controlled by :class:`RenderFormat`.
30
31 Args:
32 format: Substitution format. Defaults to :attr:`RenderFormat.F_STRING`.
33 """
34
35 def __init__(self, format: RenderFormat = RenderFormat.F_STRING) -> None:
36 self._format = format
37
38 @property
39 def format(self) -> RenderFormat:
40 """The active rendering format."""
41 return self._format
42
43 def render(self, template: str, variables: dict[str, Any]) -> str:
44 """Substitute *variables* into *template*.
45
46 Args:
47 template: Raw template string.
48 variables: Variable name → value mapping.
49
50 Returns:
51 Rendered string.
52
53 Raises:
54 :class:`~lexigram.ai.prompt.exceptions.PromptRenderError`:
55 A required placeholder has no matching variable.
56 """
57 try:
58 if self._format == RenderFormat.SIMPLE:
59 return template
60 if self._format == RenderFormat.JINJA2:
61 return self._render_jinja2(template, variables)
62 if self._format == RenderFormat.DOLLAR:
63 return Template(template).substitute(variables)
64 # F_STRING (default)
65 return template.format_map(_DefaultFormatMap(variables))
66 except PromptRenderError:
67 raise
68 except KeyError as exc:
69 raise PromptRenderError(f"Missing variable {exc} in template.") from exc
70 except ValueError as exc:
71 raise PromptRenderError(str(exc)) from exc
72
73 @staticmethod
74 def _render_jinja2(template: str, variables: dict[str, Any]) -> str:
75 """Render a Jinja2 template string."""
76 try:
77 from jinja2 import StrictUndefined
78 from jinja2 import Template as Jinja2Template
79 from jinja2.exceptions import TemplateError
80 except ImportError as exc:
81 raise PromptRenderError(
82 "Jinja2 is not installed. Add the 'jinja2' extra: "
83 "pip install lexigram-ai-platform[jinja2]"
84 ) from exc
85 try:
86 tmpl = Jinja2Template(template, undefined=StrictUndefined)
87 return tmpl.render(**variables)
88 except TemplateError as exc:
89 raise PromptRenderError(str(exc)) from exc
90
91 def get_variables(self, template: str) -> list[str]:
92 """Extract placeholder names from *template*.
93
94 Args:
95 template: Raw template string.
96
97 Returns:
98 Unique list of variable names detected in the template.
99 """
100 if self._format == RenderFormat.SIMPLE:
101 return []
102 if self._format == RenderFormat.JINJA2:
103 # Find {{ var }} style (simple names only — not expressions)
104 return list(dict.fromkeys(re.findall(r"\{\{\s*(\w+)\s*\}\}", template)))
105 if self._format == RenderFormat.DOLLAR:
106 return list(dict.fromkeys(re.findall(r"\$\{?(\w+)\}?", template)))
107 # F_STRING
108 return list(dict.fromkeys(re.findall(r"\{(\w+)\}", template)))
109
110
111class _DefaultFormatMap(dict):
112 """``str.format_map`` helper that raises ``KeyError`` for missing keys."""
113
114 def __missing__(self, key: str) -> str:
115 raise KeyError(key)
116
117
118__all__ = ["PromptRenderer", "RenderFormat"]