flashlite 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flashlite/__init__.py +169 -0
- flashlite/cache/__init__.py +14 -0
- flashlite/cache/base.py +194 -0
- flashlite/cache/disk.py +285 -0
- flashlite/cache/memory.py +157 -0
- flashlite/client.py +671 -0
- flashlite/config.py +154 -0
- flashlite/conversation/__init__.py +30 -0
- flashlite/conversation/context.py +319 -0
- flashlite/conversation/manager.py +385 -0
- flashlite/conversation/multi_agent.py +378 -0
- flashlite/core/__init__.py +13 -0
- flashlite/core/completion.py +145 -0
- flashlite/core/messages.py +130 -0
- flashlite/middleware/__init__.py +18 -0
- flashlite/middleware/base.py +90 -0
- flashlite/middleware/cache.py +121 -0
- flashlite/middleware/logging.py +159 -0
- flashlite/middleware/rate_limit.py +211 -0
- flashlite/middleware/retry.py +149 -0
- flashlite/observability/__init__.py +34 -0
- flashlite/observability/callbacks.py +155 -0
- flashlite/observability/inspect_compat.py +266 -0
- flashlite/observability/logging.py +293 -0
- flashlite/observability/metrics.py +221 -0
- flashlite/py.typed +0 -0
- flashlite/structured/__init__.py +31 -0
- flashlite/structured/outputs.py +189 -0
- flashlite/structured/schema.py +165 -0
- flashlite/templating/__init__.py +11 -0
- flashlite/templating/engine.py +217 -0
- flashlite/templating/filters.py +143 -0
- flashlite/templating/registry.py +165 -0
- flashlite/tools/__init__.py +74 -0
- flashlite/tools/definitions.py +382 -0
- flashlite/tools/execution.py +353 -0
- flashlite/types.py +233 -0
- flashlite-0.1.0.dist-info/METADATA +173 -0
- flashlite-0.1.0.dist-info/RECORD +41 -0
- flashlite-0.1.0.dist-info/WHEEL +4 -0
- flashlite-0.1.0.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Structured output parsing and validation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
from ..types import CompletionResponse
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound=BaseModel)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StructuredOutputError(Exception):
|
|
18
|
+
"""Error parsing or validating structured output."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
raw_content: str | None = None,
|
|
24
|
+
validation_errors: list[dict] | None = None,
|
|
25
|
+
):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.raw_content = raw_content
|
|
28
|
+
self.validation_errors = validation_errors or []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_json_response(content: str) -> dict:
|
|
32
|
+
"""
|
|
33
|
+
Parse JSON from an LLM response.
|
|
34
|
+
|
|
35
|
+
Handles common cases where the model wraps JSON in markdown code blocks
|
|
36
|
+
or includes extra text.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
content: The raw response content from the LLM
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Parsed JSON as a dict
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
StructuredOutputError: If JSON cannot be parsed
|
|
46
|
+
"""
|
|
47
|
+
# Strip whitespace
|
|
48
|
+
content = content.strip()
|
|
49
|
+
|
|
50
|
+
# Try direct parse first
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(content)
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# Try to extract JSON from markdown code blocks
|
|
57
|
+
# Match ```json ... ``` or ``` ... ```
|
|
58
|
+
json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
|
|
59
|
+
matches = re.findall(json_block_pattern, content)
|
|
60
|
+
|
|
61
|
+
for match in matches:
|
|
62
|
+
try:
|
|
63
|
+
return json.loads(match.strip())
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Try to find JSON object boundaries
|
|
68
|
+
# Look for first { and last }
|
|
69
|
+
first_brace = content.find("{")
|
|
70
|
+
last_brace = content.rfind("}")
|
|
71
|
+
|
|
72
|
+
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
|
73
|
+
try:
|
|
74
|
+
return json.loads(content[first_brace : last_brace + 1])
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# Try to find JSON array boundaries
|
|
79
|
+
first_bracket = content.find("[")
|
|
80
|
+
last_bracket = content.rfind("]")
|
|
81
|
+
|
|
82
|
+
if first_bracket != -1 and last_bracket != -1 and last_bracket > first_bracket:
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(content[first_bracket : last_bracket + 1])
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
raise StructuredOutputError(
|
|
89
|
+
f"Could not parse JSON from response: {content[:200]}...",
|
|
90
|
+
raw_content=content,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_response(
|
|
95
|
+
response: CompletionResponse,
|
|
96
|
+
model: type[T],
|
|
97
|
+
) -> T:
|
|
98
|
+
"""
|
|
99
|
+
Parse and validate a completion response against a Pydantic model.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
response: The completion response to validate
|
|
103
|
+
model: The Pydantic model class to validate against
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A validated instance of the model
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
StructuredOutputError: If parsing or validation fails
|
|
110
|
+
"""
|
|
111
|
+
content = response.content
|
|
112
|
+
|
|
113
|
+
# Parse JSON
|
|
114
|
+
try:
|
|
115
|
+
data = parse_json_response(content)
|
|
116
|
+
except StructuredOutputError:
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
# Validate against model
|
|
120
|
+
try:
|
|
121
|
+
return model.model_validate(data)
|
|
122
|
+
except ValidationError as e:
|
|
123
|
+
errors = e.errors()
|
|
124
|
+
error_messages = []
|
|
125
|
+
for err in errors:
|
|
126
|
+
loc = ".".join(str(x) for x in err["loc"])
|
|
127
|
+
msg = err["msg"]
|
|
128
|
+
error_messages.append(f" - {loc}: {msg}")
|
|
129
|
+
|
|
130
|
+
raise StructuredOutputError(
|
|
131
|
+
"Validation failed:\n" + "\n".join(error_messages),
|
|
132
|
+
raw_content=content,
|
|
133
|
+
validation_errors=[dict(e) for e in errors],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def format_validation_error_for_retry(error: StructuredOutputError) -> str:
|
|
138
|
+
"""
|
|
139
|
+
Format a validation error as feedback for the model to retry.
|
|
140
|
+
|
|
141
|
+
This creates a message that explains what went wrong so the model
|
|
142
|
+
can correct its response.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
error: The structured output error
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
A formatted error message for the retry prompt
|
|
149
|
+
"""
|
|
150
|
+
lines = ["Your previous response had the following errors:", ""]
|
|
151
|
+
|
|
152
|
+
if error.validation_errors:
|
|
153
|
+
for err in error.validation_errors:
|
|
154
|
+
loc = ".".join(str(x) for x in err.get("loc", []))
|
|
155
|
+
msg = err.get("msg", "Unknown error")
|
|
156
|
+
input_val = err.get("input")
|
|
157
|
+
|
|
158
|
+
if loc:
|
|
159
|
+
lines.append(f"- Field '{loc}': {msg}")
|
|
160
|
+
if input_val is not None:
|
|
161
|
+
lines.append(f" Got: {input_val!r}")
|
|
162
|
+
else:
|
|
163
|
+
lines.append(f"- {msg}")
|
|
164
|
+
else:
|
|
165
|
+
lines.append(f"- {str(error)}")
|
|
166
|
+
|
|
167
|
+
lines.append("")
|
|
168
|
+
lines.append("Please correct these errors and respond with valid JSON.")
|
|
169
|
+
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def extract_json_from_content(content: str) -> str | None:
|
|
174
|
+
"""
|
|
175
|
+
Extract just the JSON portion from content that may contain other text.
|
|
176
|
+
|
|
177
|
+
Returns the JSON string if found, None otherwise.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
content: The raw content
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The extracted JSON string or None
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
data = parse_json_response(content)
|
|
187
|
+
return json.dumps(data)
|
|
188
|
+
except StructuredOutputError:
|
|
189
|
+
return None
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""JSON schema generation from Pydantic models."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_json_schema(model: type[BaseModel]) -> dict[str, Any]:
|
|
10
|
+
"""
|
|
11
|
+
Generate a JSON schema from a Pydantic model.
|
|
12
|
+
|
|
13
|
+
This uses Pydantic's built-in schema generation and formats it
|
|
14
|
+
for use with LLM structured output features.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
model: A Pydantic BaseModel class
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
JSON schema dict suitable for LLM prompts or response_format
|
|
21
|
+
"""
|
|
22
|
+
# Use Pydantic's built-in schema generation
|
|
23
|
+
schema = model.model_json_schema()
|
|
24
|
+
|
|
25
|
+
# Clean up schema for LLM consumption
|
|
26
|
+
schema = _simplify_schema(schema)
|
|
27
|
+
|
|
28
|
+
return schema
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _simplify_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
"""
|
|
33
|
+
Simplify a JSON schema for LLM consumption.
|
|
34
|
+
|
|
35
|
+
Removes Pydantic-specific fields and flattens $defs references
|
|
36
|
+
where possible for cleaner prompts.
|
|
37
|
+
"""
|
|
38
|
+
# Remove Pydantic-specific metadata
|
|
39
|
+
keys_to_remove = ["title", "$defs", "definitions"]
|
|
40
|
+
|
|
41
|
+
# If there are $defs, inline them
|
|
42
|
+
defs = schema.get("$defs", schema.get("definitions", {}))
|
|
43
|
+
|
|
44
|
+
def resolve_refs(obj: Any) -> Any:
|
|
45
|
+
"""Recursively resolve $ref references."""
|
|
46
|
+
if isinstance(obj, dict):
|
|
47
|
+
if "$ref" in obj:
|
|
48
|
+
ref_path = obj["$ref"]
|
|
49
|
+
# Extract the definition name from "#/$defs/Name"
|
|
50
|
+
if ref_path.startswith("#/$defs/"):
|
|
51
|
+
def_name = ref_path.split("/")[-1]
|
|
52
|
+
if def_name in defs:
|
|
53
|
+
# Return a copy of the resolved definition
|
|
54
|
+
resolved = resolve_refs(defs[def_name].copy())
|
|
55
|
+
# Remove title from inlined definitions
|
|
56
|
+
resolved.pop("title", None)
|
|
57
|
+
return resolved
|
|
58
|
+
elif ref_path.startswith("#/definitions/"):
|
|
59
|
+
def_name = ref_path.split("/")[-1]
|
|
60
|
+
if def_name in defs:
|
|
61
|
+
resolved = resolve_refs(defs[def_name].copy())
|
|
62
|
+
resolved.pop("title", None)
|
|
63
|
+
return resolved
|
|
64
|
+
return {k: resolve_refs(v) for k, v in obj.items() if k not in keys_to_remove}
|
|
65
|
+
elif isinstance(obj, list):
|
|
66
|
+
return [resolve_refs(item) for item in obj]
|
|
67
|
+
return obj
|
|
68
|
+
|
|
69
|
+
result = resolve_refs(schema)
|
|
70
|
+
|
|
71
|
+
# Remove top-level title if present
|
|
72
|
+
result.pop("title", None)
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def schema_to_prompt(model: type[BaseModel]) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Convert a Pydantic model to a prompt-friendly schema description.
|
|
80
|
+
|
|
81
|
+
This generates a human-readable description of the expected JSON
|
|
82
|
+
structure that can be included in system prompts.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
model: A Pydantic BaseModel class
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A string describing the expected JSON format
|
|
89
|
+
"""
|
|
90
|
+
schema = generate_json_schema(model)
|
|
91
|
+
|
|
92
|
+
lines = ["You must respond with valid JSON matching this schema:", ""]
|
|
93
|
+
lines.append("```json")
|
|
94
|
+
lines.append(json.dumps(schema, indent=2))
|
|
95
|
+
lines.append("```")
|
|
96
|
+
lines.append("")
|
|
97
|
+
lines.append("Important:")
|
|
98
|
+
lines.append("- Respond ONLY with the JSON object, no other text")
|
|
99
|
+
lines.append("- Ensure all required fields are present")
|
|
100
|
+
lines.append("- Use the exact field names and types specified")
|
|
101
|
+
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_field_descriptions(model: type[BaseModel]) -> dict[str, str]:
|
|
106
|
+
"""
|
|
107
|
+
Extract field descriptions from a Pydantic model.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
model: A Pydantic BaseModel class
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dict mapping field names to their descriptions
|
|
114
|
+
"""
|
|
115
|
+
descriptions = {}
|
|
116
|
+
for field_name, field_info in model.model_fields.items():
|
|
117
|
+
if field_info.description:
|
|
118
|
+
descriptions[field_name] = field_info.description
|
|
119
|
+
return descriptions
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def format_schema_for_openai(model: type[BaseModel]) -> dict[str, Any]:
|
|
123
|
+
"""
|
|
124
|
+
Format a schema for OpenAI's structured outputs feature.
|
|
125
|
+
|
|
126
|
+
OpenAI's structured outputs (response_format with json_schema)
|
|
127
|
+
requires a specific format with name and strict fields.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
model: A Pydantic BaseModel class
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict formatted for OpenAI's response_format parameter
|
|
134
|
+
"""
|
|
135
|
+
schema = model.model_json_schema()
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"type": "json_schema",
|
|
139
|
+
"json_schema": {
|
|
140
|
+
"name": model.__name__,
|
|
141
|
+
"strict": True,
|
|
142
|
+
"schema": schema,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_supported_type(model: type[BaseModel]) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Check if a Pydantic model uses only supported types for structured outputs.
|
|
150
|
+
|
|
151
|
+
Most types are supported, but this can help identify potential issues.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
model: A Pydantic BaseModel class
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
True if all field types are supported
|
|
158
|
+
"""
|
|
159
|
+
# For now, we support all types that Pydantic can serialize to JSON schema
|
|
160
|
+
# This could be expanded to check for specific unsupported types
|
|
161
|
+
try:
|
|
162
|
+
model.model_json_schema()
|
|
163
|
+
return True
|
|
164
|
+
except Exception:
|
|
165
|
+
return False
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Jinja template engine for prompt templating."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from jinja2 import (
|
|
7
|
+
BaseLoader,
|
|
8
|
+
Environment,
|
|
9
|
+
FileSystemLoader,
|
|
10
|
+
StrictUndefined,
|
|
11
|
+
Template,
|
|
12
|
+
TemplateNotFound,
|
|
13
|
+
Undefined,
|
|
14
|
+
UndefinedError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ..types import TemplateError
|
|
18
|
+
from .filters import register_default_filters
|
|
19
|
+
from .registry import TemplateRegistry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RegistryLoader(BaseLoader):
|
|
23
|
+
"""Jinja loader that loads templates from a TemplateRegistry."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, registry: TemplateRegistry):
|
|
26
|
+
self.registry = registry
|
|
27
|
+
|
|
28
|
+
def get_source(
|
|
29
|
+
self, environment: Environment, template: str
|
|
30
|
+
) -> tuple[str, str | None, Any]:
|
|
31
|
+
if not self.registry.has(template):
|
|
32
|
+
raise TemplateNotFound(template)
|
|
33
|
+
source = self.registry.get_source(template)
|
|
34
|
+
return source, template, lambda: True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TemplateEngine:
|
|
38
|
+
"""
|
|
39
|
+
Main template engine for rendering prompts.
|
|
40
|
+
|
|
41
|
+
Features:
|
|
42
|
+
- File-based templates from a directory
|
|
43
|
+
- In-memory template registry
|
|
44
|
+
- Custom filters for common operations
|
|
45
|
+
- Strict undefined checking (errors on missing variables)
|
|
46
|
+
- Variable validation
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
template_dir: Path | str | None = None,
|
|
52
|
+
strict: bool = True,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize the template engine.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
template_dir: Optional directory to load templates from
|
|
59
|
+
strict: If True, raise error on undefined variables
|
|
60
|
+
"""
|
|
61
|
+
self.registry = TemplateRegistry()
|
|
62
|
+
self._template_dir = Path(template_dir) if template_dir else None
|
|
63
|
+
|
|
64
|
+
# Create loaders
|
|
65
|
+
loaders: list[BaseLoader] = [RegistryLoader(self.registry)]
|
|
66
|
+
if self._template_dir and self._template_dir.exists():
|
|
67
|
+
loaders.append(FileSystemLoader(str(self._template_dir)))
|
|
68
|
+
|
|
69
|
+
# Create Jinja environment
|
|
70
|
+
# Using ChoiceLoader to try registry first, then filesystem
|
|
71
|
+
from jinja2 import ChoiceLoader
|
|
72
|
+
|
|
73
|
+
self.env = Environment(
|
|
74
|
+
loader=ChoiceLoader(loaders),
|
|
75
|
+
undefined=StrictUndefined if strict else Undefined,
|
|
76
|
+
autoescape=False, # Don't HTML-escape (we're making prompts, not HTML)
|
|
77
|
+
trim_blocks=True,
|
|
78
|
+
lstrip_blocks=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Register default filters
|
|
82
|
+
register_default_filters(self.env)
|
|
83
|
+
|
|
84
|
+
# Load templates from directory if provided
|
|
85
|
+
if self._template_dir and self._template_dir.exists():
|
|
86
|
+
self.registry.load_from_directory(self._template_dir)
|
|
87
|
+
|
|
88
|
+
def render(
|
|
89
|
+
self,
|
|
90
|
+
template: str,
|
|
91
|
+
variables: dict[str, Any] | None = None,
|
|
92
|
+
validate: bool = True,
|
|
93
|
+
) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Render a template with variables.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
template: Template name or inline template string
|
|
99
|
+
variables: Variables to pass to the template
|
|
100
|
+
validate: If True, validate that all variables are provided
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Rendered template string
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
TemplateError: If template not found or rendering fails
|
|
107
|
+
"""
|
|
108
|
+
variables = variables or {}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# Try to get from registry/filesystem first
|
|
112
|
+
try:
|
|
113
|
+
tpl = self.env.get_template(template)
|
|
114
|
+
except TemplateNotFound:
|
|
115
|
+
# Treat as inline template string
|
|
116
|
+
tpl = self.env.from_string(template)
|
|
117
|
+
|
|
118
|
+
if validate:
|
|
119
|
+
self._validate_variables(tpl, variables)
|
|
120
|
+
|
|
121
|
+
return tpl.render(**variables)
|
|
122
|
+
|
|
123
|
+
except UndefinedError as e:
|
|
124
|
+
raise TemplateError(f"Missing template variable: {e}") from e
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise TemplateError(f"Template rendering failed: {e}") from e
|
|
127
|
+
|
|
128
|
+
def render_string(
|
|
129
|
+
self,
|
|
130
|
+
template_string: str,
|
|
131
|
+
variables: dict[str, Any] | None = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Render an inline template string.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
template_string: The template source string
|
|
138
|
+
variables: Variables to pass to the template
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Rendered string
|
|
142
|
+
"""
|
|
143
|
+
variables = variables or {}
|
|
144
|
+
try:
|
|
145
|
+
tpl = self.env.from_string(template_string)
|
|
146
|
+
return tpl.render(**variables)
|
|
147
|
+
except UndefinedError as e:
|
|
148
|
+
raise TemplateError(f"Missing template variable: {e}") from e
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise TemplateError(f"Template rendering failed: {e}") from e
|
|
151
|
+
|
|
152
|
+
def register(self, name: str, template: str) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Register an in-memory template.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
name: Template name
|
|
158
|
+
template: Template source string
|
|
159
|
+
"""
|
|
160
|
+
self.registry.register(name, template)
|
|
161
|
+
|
|
162
|
+
def get_variables(self, template: str) -> set[str]:
|
|
163
|
+
"""
|
|
164
|
+
Get the variables used in a template.
|
|
165
|
+
|
|
166
|
+
Note: This uses AST analysis and may not catch all dynamic variables.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
template: Template name or inline template string
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Set of variable names
|
|
173
|
+
"""
|
|
174
|
+
from jinja2 import meta
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
try:
|
|
178
|
+
self.env.get_template(template) # Validate template exists
|
|
179
|
+
source = self.registry.get_source(template)
|
|
180
|
+
except TemplateNotFound:
|
|
181
|
+
source = template
|
|
182
|
+
|
|
183
|
+
ast = self.env.parse(source)
|
|
184
|
+
return meta.find_undeclared_variables(ast)
|
|
185
|
+
except Exception:
|
|
186
|
+
return set()
|
|
187
|
+
|
|
188
|
+
def _validate_variables(self, template: Template, variables: dict[str, Any]) -> None:
|
|
189
|
+
"""Validate that all required variables are provided."""
|
|
190
|
+
from jinja2 import meta
|
|
191
|
+
|
|
192
|
+
if not hasattr(template, "source"):
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
ast = self.env.parse(template.source)
|
|
197
|
+
required = meta.find_undeclared_variables(ast)
|
|
198
|
+
provided = set(variables.keys())
|
|
199
|
+
missing = required - provided
|
|
200
|
+
|
|
201
|
+
if missing:
|
|
202
|
+
raise TemplateError(
|
|
203
|
+
f"Missing required template variables: {', '.join(sorted(missing))}"
|
|
204
|
+
)
|
|
205
|
+
except TemplateError:
|
|
206
|
+
raise
|
|
207
|
+
except Exception:
|
|
208
|
+
# If we can't parse, let rendering catch the error
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
def add_filter(self, name: str, func: Any) -> None:
|
|
212
|
+
"""Add a custom filter to the environment."""
|
|
213
|
+
self.env.filters[name] = func
|
|
214
|
+
|
|
215
|
+
def add_global(self, name: str, value: Any) -> None:
|
|
216
|
+
"""Add a global variable available in all templates."""
|
|
217
|
+
self.env.globals[name] = value
|