ostruct-cli 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.
- ostruct/__init__.py +0 -0
- ostruct/cli/__init__.py +19 -0
- ostruct/cli/cache_manager.py +175 -0
- ostruct/cli/cli.py +2033 -0
- ostruct/cli/errors.py +329 -0
- ostruct/cli/file_info.py +316 -0
- ostruct/cli/file_list.py +151 -0
- ostruct/cli/file_utils.py +518 -0
- ostruct/cli/path_utils.py +123 -0
- ostruct/cli/progress.py +105 -0
- ostruct/cli/security.py +311 -0
- ostruct/cli/security_types.py +49 -0
- ostruct/cli/template_env.py +55 -0
- ostruct/cli/template_extensions.py +51 -0
- ostruct/cli/template_filters.py +650 -0
- ostruct/cli/template_io.py +261 -0
- ostruct/cli/template_rendering.py +347 -0
- ostruct/cli/template_schema.py +565 -0
- ostruct/cli/template_utils.py +288 -0
- ostruct/cli/template_validation.py +375 -0
- ostruct/cli/utils.py +31 -0
- ostruct/py.typed +0 -0
- ostruct_cli-0.1.0.dist-info/LICENSE +21 -0
- ostruct_cli-0.1.0.dist-info/METADATA +182 -0
- ostruct_cli-0.1.0.dist-info/RECORD +27 -0
- ostruct_cli-0.1.0.dist-info/WHEEL +4 -0
- ostruct_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,288 @@
|
|
1
|
+
"""Template utilities for the CLI.
|
2
|
+
|
3
|
+
This module serves as the main entry point for template processing functionality.
|
4
|
+
It re-exports the public APIs from specialized modules:
|
5
|
+
|
6
|
+
- template_schema: Schema validation and proxy objects
|
7
|
+
- template_validation: Template validation using Jinja2
|
8
|
+
- template_rendering: Template rendering with Jinja2
|
9
|
+
- template_io: File I/O operations and metadata extraction
|
10
|
+
"""
|
11
|
+
|
12
|
+
from typing import Any, Dict, List, Optional, Set
|
13
|
+
|
14
|
+
import jsonschema
|
15
|
+
from jinja2 import Environment, meta
|
16
|
+
from jinja2.nodes import Node
|
17
|
+
|
18
|
+
from .errors import (
|
19
|
+
CLIError,
|
20
|
+
SchemaError,
|
21
|
+
SchemaValidationError,
|
22
|
+
SystemPromptError,
|
23
|
+
TaskTemplateError,
|
24
|
+
TaskTemplateSyntaxError,
|
25
|
+
TaskTemplateVariableError,
|
26
|
+
)
|
27
|
+
from .file_utils import FileInfo
|
28
|
+
from .template_io import extract_metadata, extract_template_metadata, read_file
|
29
|
+
from .template_rendering import DotDict, render_template
|
30
|
+
from .template_schema import (
|
31
|
+
DictProxy,
|
32
|
+
FileInfoProxy,
|
33
|
+
ValidationProxy,
|
34
|
+
create_validation_context,
|
35
|
+
)
|
36
|
+
from .template_validation import SafeUndefined, validate_template_placeholders
|
37
|
+
|
38
|
+
|
39
|
+
# Custom error classes
|
40
|
+
class TemplateMetadataError(TaskTemplateError):
|
41
|
+
"""Raised when there are issues extracting template metadata."""
|
42
|
+
|
43
|
+
pass
|
44
|
+
|
45
|
+
|
46
|
+
def validate_json_schema(schema: Dict[str, Any]) -> None:
|
47
|
+
"""Validate that a dictionary follows JSON Schema structure.
|
48
|
+
|
49
|
+
This function checks that the provided dictionary is a valid JSON Schema,
|
50
|
+
following the JSON Schema specification.
|
51
|
+
|
52
|
+
Args:
|
53
|
+
schema: Dictionary to validate as a JSON Schema
|
54
|
+
|
55
|
+
Raises:
|
56
|
+
SchemaValidationError: If the schema is invalid
|
57
|
+
"""
|
58
|
+
try:
|
59
|
+
# Get the validator class for the schema
|
60
|
+
validator_cls = jsonschema.validators.validator_for(schema)
|
61
|
+
|
62
|
+
# Check schema itself is valid
|
63
|
+
validator_cls.check_schema(schema)
|
64
|
+
|
65
|
+
# Create validator instance
|
66
|
+
validator_cls(schema)
|
67
|
+
except jsonschema.exceptions.SchemaError as e:
|
68
|
+
raise SchemaValidationError(f"Invalid JSON Schema: {e}")
|
69
|
+
except Exception as e:
|
70
|
+
raise SchemaValidationError(f"Schema validation error: {e}")
|
71
|
+
|
72
|
+
|
73
|
+
def validate_response(
|
74
|
+
response: Dict[str, Any], schema: Dict[str, Any]
|
75
|
+
) -> None:
|
76
|
+
"""Validate that a response dictionary matches a JSON Schema.
|
77
|
+
|
78
|
+
This function validates that the response dictionary conforms to the provided
|
79
|
+
JSON Schema specification.
|
80
|
+
|
81
|
+
Args:
|
82
|
+
response: Dictionary to validate
|
83
|
+
schema: JSON Schema to validate against
|
84
|
+
|
85
|
+
Raises:
|
86
|
+
ValueError: If the response does not match the schema
|
87
|
+
"""
|
88
|
+
try:
|
89
|
+
# Create a validator for the schema
|
90
|
+
validator = jsonschema.validators.validator_for(schema)(schema)
|
91
|
+
|
92
|
+
# Validate the response
|
93
|
+
validator.validate(response)
|
94
|
+
except jsonschema.exceptions.ValidationError as e:
|
95
|
+
raise ValueError(f"Response validation failed: {e}")
|
96
|
+
except Exception as e:
|
97
|
+
raise ValueError(f"Response validation error: {e}")
|
98
|
+
|
99
|
+
|
100
|
+
def find_all_template_variables(
|
101
|
+
template: str, env: Optional[Environment] = None
|
102
|
+
) -> Set[str]:
|
103
|
+
"""Find all variables used in a template and its dependencies.
|
104
|
+
|
105
|
+
This function recursively parses the template and any included/imported/extended
|
106
|
+
templates to find all variables that might be used.
|
107
|
+
|
108
|
+
Args:
|
109
|
+
template: The template string to parse
|
110
|
+
env: Optional Jinja2 environment. If not provided, a new one will be created.
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
Set of all variable names used in the template and its dependencies
|
114
|
+
|
115
|
+
Raises:
|
116
|
+
jinja2.TemplateSyntaxError: If the template has invalid syntax
|
117
|
+
"""
|
118
|
+
if env is None:
|
119
|
+
env = Environment(undefined=SafeUndefined)
|
120
|
+
|
121
|
+
# Parse template
|
122
|
+
ast = env.parse(template)
|
123
|
+
|
124
|
+
# Find all variables in this template
|
125
|
+
variables = meta.find_undeclared_variables(ast)
|
126
|
+
|
127
|
+
# Filter out built-in functions and filters
|
128
|
+
builtin_vars = {
|
129
|
+
# Core functions
|
130
|
+
"range",
|
131
|
+
"dict",
|
132
|
+
"lipsum",
|
133
|
+
"cycler",
|
134
|
+
"joiner",
|
135
|
+
"namespace",
|
136
|
+
"loop",
|
137
|
+
"super",
|
138
|
+
"self",
|
139
|
+
"varargs",
|
140
|
+
"kwargs",
|
141
|
+
"items",
|
142
|
+
# String filters
|
143
|
+
"upper",
|
144
|
+
"lower",
|
145
|
+
"title",
|
146
|
+
"capitalize",
|
147
|
+
"trim",
|
148
|
+
"strip",
|
149
|
+
"lstrip",
|
150
|
+
"rstrip",
|
151
|
+
"center",
|
152
|
+
"ljust",
|
153
|
+
"rjust",
|
154
|
+
"wordcount",
|
155
|
+
"truncate",
|
156
|
+
"striptags",
|
157
|
+
# List filters
|
158
|
+
"first",
|
159
|
+
"last",
|
160
|
+
"length",
|
161
|
+
"max",
|
162
|
+
"min",
|
163
|
+
"sum",
|
164
|
+
"sort",
|
165
|
+
"unique",
|
166
|
+
"reverse",
|
167
|
+
"reject",
|
168
|
+
"select",
|
169
|
+
"map",
|
170
|
+
"join",
|
171
|
+
"count",
|
172
|
+
# Type conversion
|
173
|
+
"abs",
|
174
|
+
"round",
|
175
|
+
"int",
|
176
|
+
"float",
|
177
|
+
"string",
|
178
|
+
"list",
|
179
|
+
"bool",
|
180
|
+
"batch",
|
181
|
+
"slice",
|
182
|
+
"attr",
|
183
|
+
# Common filters
|
184
|
+
"default",
|
185
|
+
"replace",
|
186
|
+
"safe",
|
187
|
+
"urlencode",
|
188
|
+
"indent",
|
189
|
+
"format",
|
190
|
+
"escape",
|
191
|
+
"e",
|
192
|
+
"nl2br",
|
193
|
+
"urlize",
|
194
|
+
# Dictionary operations
|
195
|
+
"items",
|
196
|
+
"keys",
|
197
|
+
"values",
|
198
|
+
"dictsort",
|
199
|
+
# Math operations
|
200
|
+
"round",
|
201
|
+
"ceil",
|
202
|
+
"floor",
|
203
|
+
"abs",
|
204
|
+
"max",
|
205
|
+
"min",
|
206
|
+
# Date/time
|
207
|
+
"now",
|
208
|
+
"strftime",
|
209
|
+
"strptime",
|
210
|
+
"datetimeformat",
|
211
|
+
}
|
212
|
+
|
213
|
+
variables = variables - builtin_vars
|
214
|
+
|
215
|
+
# Find template dependencies (include, extends, import)
|
216
|
+
def visit_nodes(nodes: List[Node]) -> None:
|
217
|
+
"""Visit AST nodes recursively to find template dependencies.
|
218
|
+
|
219
|
+
Args:
|
220
|
+
nodes: List of AST nodes to visit
|
221
|
+
"""
|
222
|
+
for node in nodes:
|
223
|
+
if node.__class__.__name__ in (
|
224
|
+
"Include",
|
225
|
+
"Extends",
|
226
|
+
"Import",
|
227
|
+
"FromImport",
|
228
|
+
):
|
229
|
+
# Get the template name
|
230
|
+
if hasattr(node, "template"):
|
231
|
+
template_name = node.template.value
|
232
|
+
try:
|
233
|
+
# Load and parse the referenced template
|
234
|
+
if env.loader is not None:
|
235
|
+
included_template = env.loader.get_source(
|
236
|
+
env, template_name
|
237
|
+
)[0]
|
238
|
+
# Recursively find variables in the included template
|
239
|
+
variables.update(
|
240
|
+
find_all_template_variables(
|
241
|
+
included_template, env
|
242
|
+
)
|
243
|
+
)
|
244
|
+
except Exception:
|
245
|
+
# Skip if template can't be loaded - it will be caught during rendering
|
246
|
+
pass
|
247
|
+
|
248
|
+
# Recursively visit child nodes
|
249
|
+
if hasattr(node, "body"):
|
250
|
+
visit_nodes(node.body)
|
251
|
+
if hasattr(node, "else_"):
|
252
|
+
visit_nodes(node.else_)
|
253
|
+
|
254
|
+
visit_nodes(ast.body)
|
255
|
+
return variables # type: ignore[no-any-return]
|
256
|
+
|
257
|
+
|
258
|
+
__all__ = [
|
259
|
+
# Schema types and validation
|
260
|
+
"ValidationProxy",
|
261
|
+
"FileInfoProxy",
|
262
|
+
"DictProxy",
|
263
|
+
"create_validation_context",
|
264
|
+
"validate_json_schema",
|
265
|
+
"validate_response",
|
266
|
+
# Template validation
|
267
|
+
"SafeUndefined",
|
268
|
+
"validate_template_placeholders",
|
269
|
+
"find_all_template_variables",
|
270
|
+
# Template rendering
|
271
|
+
"render_template",
|
272
|
+
"DotDict",
|
273
|
+
# File I/O
|
274
|
+
"read_file",
|
275
|
+
"extract_metadata",
|
276
|
+
"extract_template_metadata",
|
277
|
+
# File info
|
278
|
+
"FileInfo",
|
279
|
+
# Error classes
|
280
|
+
"CLIError",
|
281
|
+
"TaskTemplateError",
|
282
|
+
"TaskTemplateSyntaxError",
|
283
|
+
"TaskTemplateVariableError",
|
284
|
+
"SchemaError",
|
285
|
+
"SchemaValidationError",
|
286
|
+
"SystemPromptError",
|
287
|
+
"TemplateMetadataError",
|
288
|
+
]
|
@@ -0,0 +1,375 @@
|
|
1
|
+
"""Template validation using Jinja2.
|
2
|
+
|
3
|
+
This module provides functionality for validating Jinja2 templates, ensuring that:
|
4
|
+
1. All variables used in templates exist in the context
|
5
|
+
2. Nested attribute/key access is valid according to schema
|
6
|
+
3. Loop variables and filters are used correctly
|
7
|
+
|
8
|
+
Key Components:
|
9
|
+
- SafeUndefined: Custom undefined type for validation
|
10
|
+
- validate_template_placeholders: Main validation function
|
11
|
+
|
12
|
+
Examples:
|
13
|
+
Basic template validation:
|
14
|
+
>>> template = "Hello {{ name }}, your score is {{ results.score }}"
|
15
|
+
>>> template_context = {'name': 'value', 'results': {'score': 100}}
|
16
|
+
>>> validate_template_placeholders(template, template_context) # OK
|
17
|
+
>>>
|
18
|
+
>>> # Missing variable raises ValueError:
|
19
|
+
>>> template = "Hello {{ missing }}"
|
20
|
+
>>> validate_template_placeholders(template, {'name'}) # Raises ValueError
|
21
|
+
|
22
|
+
Nested attribute validation:
|
23
|
+
>>> template = '''
|
24
|
+
... Debug mode: {{ config.debug }}
|
25
|
+
... Settings: {{ config.settings.mode }}
|
26
|
+
... Invalid: {{ config.invalid }}
|
27
|
+
... '''
|
28
|
+
>>> validate_template_placeholders(template, {'config'}) # Raises ValueError
|
29
|
+
|
30
|
+
Loop variable validation:
|
31
|
+
>>> template = '''
|
32
|
+
... {% for item in items %}
|
33
|
+
... - {{ item.name }}: {{ item.value }}
|
34
|
+
... Invalid: {{ item.invalid }}
|
35
|
+
... {% endfor %}
|
36
|
+
... '''
|
37
|
+
>>> validate_template_placeholders(template, {'items'}) # Raises ValueError
|
38
|
+
|
39
|
+
Filter validation:
|
40
|
+
>>> template = '''
|
41
|
+
... {{ code | format_code }} {# OK - valid filter #}
|
42
|
+
... {{ data | invalid_filter }} {# Raises ValueError #}
|
43
|
+
... '''
|
44
|
+
>>> validate_template_placeholders(template, {'code', 'data'})
|
45
|
+
|
46
|
+
Notes:
|
47
|
+
- Uses Jinja2's meta API to find undeclared variables
|
48
|
+
- Supports custom filters through safe wrappers
|
49
|
+
- Provides detailed error messages for validation failures
|
50
|
+
"""
|
51
|
+
|
52
|
+
import logging
|
53
|
+
from typing import (
|
54
|
+
Any,
|
55
|
+
Callable,
|
56
|
+
Dict,
|
57
|
+
List,
|
58
|
+
Optional,
|
59
|
+
Set,
|
60
|
+
TypeVar,
|
61
|
+
Union,
|
62
|
+
cast,
|
63
|
+
)
|
64
|
+
|
65
|
+
import jinja2
|
66
|
+
from jinja2 import meta
|
67
|
+
from jinja2.nodes import For, Name, Node
|
68
|
+
|
69
|
+
from . import template_filters
|
70
|
+
from .errors import TemplateValidationError
|
71
|
+
from .template_env import create_jinja_env
|
72
|
+
from .template_schema import (
|
73
|
+
DictProxy,
|
74
|
+
FileInfoProxy,
|
75
|
+
ValidationProxy,
|
76
|
+
create_validation_context,
|
77
|
+
)
|
78
|
+
|
79
|
+
T = TypeVar("T")
|
80
|
+
FilterFunc = Callable[..., Any]
|
81
|
+
FilterWrapper = Callable[[Any, Any, Any], Optional[Union[Any, str, List[Any]]]]
|
82
|
+
|
83
|
+
__all__ = [
|
84
|
+
"TemplateValidationError",
|
85
|
+
"validate_template_placeholders",
|
86
|
+
"validate_template_file",
|
87
|
+
]
|
88
|
+
|
89
|
+
|
90
|
+
class SafeUndefined(jinja2.StrictUndefined): # type: ignore[misc]
|
91
|
+
"""A strict Undefined class that validates attribute access during validation."""
|
92
|
+
|
93
|
+
def __getattr__(self, name: str) -> Any:
|
94
|
+
# Raise error for attribute access on undefined values
|
95
|
+
if name not in {"__html__", "__html_format__"}:
|
96
|
+
raise jinja2.UndefinedError(
|
97
|
+
f"'{self._undefined_name}' has no attribute '{name}'"
|
98
|
+
)
|
99
|
+
return self
|
100
|
+
|
101
|
+
def __getitem__(self, key: Any) -> Any:
|
102
|
+
# Raise error for key access on undefined values
|
103
|
+
raise jinja2.UndefinedError(
|
104
|
+
f"'{self._undefined_name}' has no key '{key}'"
|
105
|
+
)
|
106
|
+
|
107
|
+
|
108
|
+
def safe_filter(func: FilterFunc) -> FilterWrapper:
|
109
|
+
"""Wrap a filter function to handle None and proxy values safely."""
|
110
|
+
|
111
|
+
def wrapper(
|
112
|
+
value: Any, *args: Any, **kwargs: Any
|
113
|
+
) -> Optional[Union[Any, str, List[Any]]]:
|
114
|
+
if value is None:
|
115
|
+
return None
|
116
|
+
if isinstance(value, (ValidationProxy, FileInfoProxy, DictProxy)):
|
117
|
+
# For validation, just return an empty result of the appropriate type
|
118
|
+
if func.__name__ in (
|
119
|
+
"extract_field",
|
120
|
+
"frequency",
|
121
|
+
"aggregate",
|
122
|
+
"pivot_table",
|
123
|
+
"summarize",
|
124
|
+
):
|
125
|
+
return []
|
126
|
+
elif func.__name__ in ("dict_to_table", "list_to_table"):
|
127
|
+
return ""
|
128
|
+
return value
|
129
|
+
return func(value, *args, **kwargs)
|
130
|
+
|
131
|
+
return wrapper
|
132
|
+
|
133
|
+
|
134
|
+
def find_loop_vars(nodes: List[Node]) -> Set[str]:
|
135
|
+
"""Find variables used in loop constructs."""
|
136
|
+
loop_vars = set()
|
137
|
+
for node in nodes:
|
138
|
+
if isinstance(node, For):
|
139
|
+
target = node.target
|
140
|
+
if isinstance(target, Name):
|
141
|
+
loop_vars.add(target.name)
|
142
|
+
elif hasattr(target, "items"):
|
143
|
+
items = cast(List[Name], target.items)
|
144
|
+
for item in items:
|
145
|
+
loop_vars.add(item.name)
|
146
|
+
if hasattr(node, "body"):
|
147
|
+
loop_vars.update(find_loop_vars(cast(List[Node], node.body)))
|
148
|
+
if hasattr(node, "else_"):
|
149
|
+
loop_vars.update(find_loop_vars(cast(List[Node], node.else_)))
|
150
|
+
return loop_vars
|
151
|
+
|
152
|
+
|
153
|
+
def validate_template_placeholders(
|
154
|
+
template: str, template_context: Optional[Dict[str, Any]] = None
|
155
|
+
) -> None:
|
156
|
+
"""Validate that all placeholders in a template are valid.
|
157
|
+
|
158
|
+
Args:
|
159
|
+
template: Template string to validate
|
160
|
+
template_context: Optional context to validate against
|
161
|
+
|
162
|
+
Raises:
|
163
|
+
TemplateValidationError: If any placeholders are invalid
|
164
|
+
"""
|
165
|
+
logger = logging.getLogger(__name__)
|
166
|
+
|
167
|
+
logger.debug("=== validate_template_placeholders called ===")
|
168
|
+
logger.debug(
|
169
|
+
"Args: template=%s, template_context=%s",
|
170
|
+
template,
|
171
|
+
{
|
172
|
+
k: type(v).__name__
|
173
|
+
for k, v in (template_context or {}).items()
|
174
|
+
if v is not None
|
175
|
+
},
|
176
|
+
)
|
177
|
+
|
178
|
+
try:
|
179
|
+
# 1) Create Jinja2 environment with meta extension and safe undefined
|
180
|
+
env = create_jinja_env(validation_mode=True)
|
181
|
+
|
182
|
+
# Register custom filters with None-safe wrappers
|
183
|
+
env.filters.update(
|
184
|
+
{
|
185
|
+
"format_code": safe_filter(template_filters.format_code),
|
186
|
+
"strip_comments": safe_filter(template_filters.strip_comments),
|
187
|
+
"extract_field": safe_filter(template_filters.extract_field),
|
188
|
+
"frequency": safe_filter(template_filters.frequency),
|
189
|
+
"aggregate": safe_filter(template_filters.aggregate),
|
190
|
+
"pivot_table": safe_filter(template_filters.pivot_table),
|
191
|
+
"summarize": safe_filter(template_filters.summarize),
|
192
|
+
"dict_to_table": safe_filter(template_filters.dict_to_table),
|
193
|
+
"list_to_table": safe_filter(template_filters.list_to_table),
|
194
|
+
}
|
195
|
+
)
|
196
|
+
|
197
|
+
# Add built-in Jinja2 functions and filters
|
198
|
+
builtin_vars = {
|
199
|
+
# Core functions
|
200
|
+
"range",
|
201
|
+
"dict",
|
202
|
+
"lipsum",
|
203
|
+
"cycler",
|
204
|
+
"joiner",
|
205
|
+
"namespace",
|
206
|
+
"loop",
|
207
|
+
"super",
|
208
|
+
"self",
|
209
|
+
"varargs",
|
210
|
+
"kwargs",
|
211
|
+
"items",
|
212
|
+
# String filters
|
213
|
+
"upper",
|
214
|
+
"lower",
|
215
|
+
"title",
|
216
|
+
"capitalize",
|
217
|
+
"trim",
|
218
|
+
"strip",
|
219
|
+
"lstrip",
|
220
|
+
"rstrip",
|
221
|
+
"center",
|
222
|
+
"ljust",
|
223
|
+
"rjust",
|
224
|
+
"wordcount",
|
225
|
+
"truncate",
|
226
|
+
"striptags",
|
227
|
+
# List filters
|
228
|
+
"first",
|
229
|
+
"last",
|
230
|
+
"length",
|
231
|
+
"max",
|
232
|
+
"min",
|
233
|
+
"sum",
|
234
|
+
"sort",
|
235
|
+
"unique",
|
236
|
+
"reverse",
|
237
|
+
"reject",
|
238
|
+
"select",
|
239
|
+
"map",
|
240
|
+
"join",
|
241
|
+
"count",
|
242
|
+
# Type conversion
|
243
|
+
"abs",
|
244
|
+
"round",
|
245
|
+
"int",
|
246
|
+
"float",
|
247
|
+
"string",
|
248
|
+
"list",
|
249
|
+
"bool",
|
250
|
+
"batch",
|
251
|
+
"slice",
|
252
|
+
"attr",
|
253
|
+
# Common filters
|
254
|
+
"default",
|
255
|
+
"replace",
|
256
|
+
"safe",
|
257
|
+
"urlencode",
|
258
|
+
"indent",
|
259
|
+
"format",
|
260
|
+
"escape",
|
261
|
+
"e",
|
262
|
+
"nl2br",
|
263
|
+
"urlize",
|
264
|
+
# Dictionary operations
|
265
|
+
"items",
|
266
|
+
"keys",
|
267
|
+
"values",
|
268
|
+
"dictsort",
|
269
|
+
# Math operations
|
270
|
+
"round",
|
271
|
+
"ceil",
|
272
|
+
"floor",
|
273
|
+
"abs",
|
274
|
+
"max",
|
275
|
+
"min",
|
276
|
+
# Date/time
|
277
|
+
"now",
|
278
|
+
"strftime",
|
279
|
+
"strptime",
|
280
|
+
"datetimeformat",
|
281
|
+
}
|
282
|
+
builtin_vars.update(env.filters.keys())
|
283
|
+
builtin_vars.update(env.globals.keys())
|
284
|
+
|
285
|
+
# 2) Parse template and find variables
|
286
|
+
ast = env.parse(template)
|
287
|
+
available_vars = (
|
288
|
+
set(template_context.keys()) if template_context else set()
|
289
|
+
)
|
290
|
+
logger.debug("Available variables: %s", available_vars)
|
291
|
+
|
292
|
+
# Find loop variables
|
293
|
+
loop_vars = find_loop_vars(ast.body)
|
294
|
+
logger.debug("Found loop variables: %s", loop_vars)
|
295
|
+
|
296
|
+
# Find all undeclared variables using jinja2.meta
|
297
|
+
found_vars = meta.find_undeclared_variables(ast)
|
298
|
+
logger.debug("Found undeclared variables: %s", found_vars)
|
299
|
+
|
300
|
+
# Check for missing variables
|
301
|
+
missing = {
|
302
|
+
name
|
303
|
+
for name in found_vars
|
304
|
+
if name not in available_vars
|
305
|
+
and name not in builtin_vars
|
306
|
+
and name not in loop_vars
|
307
|
+
and name
|
308
|
+
!= "stdin" # Special case for stdin which may be added dynamically
|
309
|
+
}
|
310
|
+
|
311
|
+
if missing:
|
312
|
+
raise TemplateValidationError(
|
313
|
+
f"Task template uses undefined variables: {', '.join(sorted(missing))}. "
|
314
|
+
f"Available variables: {', '.join(sorted(available_vars))}"
|
315
|
+
)
|
316
|
+
|
317
|
+
logger.debug(
|
318
|
+
"Before create_validation_context - available_vars type: %s, value: %s",
|
319
|
+
type(available_vars),
|
320
|
+
available_vars,
|
321
|
+
)
|
322
|
+
logger.debug(
|
323
|
+
"Before create_validation_context - template_context type: %s, value: %s",
|
324
|
+
type(template_context),
|
325
|
+
template_context,
|
326
|
+
)
|
327
|
+
|
328
|
+
# 3) Create validation context with proxy objects
|
329
|
+
logger.debug(
|
330
|
+
"Creating validation context with template_context: %s",
|
331
|
+
template_context,
|
332
|
+
)
|
333
|
+
validation_context = create_validation_context(template_context or {})
|
334
|
+
|
335
|
+
# 4) Try to render with validation context
|
336
|
+
try:
|
337
|
+
env.from_string(template).render(validation_context)
|
338
|
+
except jinja2.UndefinedError as e:
|
339
|
+
# Convert Jinja2 undefined errors to our own ValueError
|
340
|
+
raise TemplateValidationError(str(e))
|
341
|
+
except ValueError as e:
|
342
|
+
# Convert validation errors from template_schema to TemplateValidationError
|
343
|
+
raise TemplateValidationError(str(e))
|
344
|
+
except Exception as e:
|
345
|
+
logger.error(
|
346
|
+
"Unexpected error during template validation: %s", str(e)
|
347
|
+
)
|
348
|
+
raise
|
349
|
+
|
350
|
+
except jinja2.TemplateSyntaxError as e:
|
351
|
+
# Convert Jinja2 syntax errors to our own ValueError
|
352
|
+
raise TemplateValidationError(
|
353
|
+
f"Invalid task template syntax: {str(e)}"
|
354
|
+
)
|
355
|
+
except Exception as e:
|
356
|
+
logger.error("Unexpected error during template validation: %s", str(e))
|
357
|
+
raise
|
358
|
+
|
359
|
+
|
360
|
+
def validate_template_file(
|
361
|
+
template_path: str,
|
362
|
+
template_context: Optional[Dict[str, Any]] = None,
|
363
|
+
) -> None:
|
364
|
+
"""Validate a template file with the given context.
|
365
|
+
|
366
|
+
Args:
|
367
|
+
template_path: Path to the template file
|
368
|
+
template_context: Optional dictionary containing template variables
|
369
|
+
|
370
|
+
Raises:
|
371
|
+
TemplateValidationError: If template validation fails
|
372
|
+
"""
|
373
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
374
|
+
template_str = f.read()
|
375
|
+
validate_template_placeholders(template_str, template_context)
|
ostruct/cli/utils.py
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
"""Common utilities for the CLI package."""
|
2
|
+
|
3
|
+
from typing import Tuple
|
4
|
+
|
5
|
+
from .errors import VariableNameError, VariableValueError
|
6
|
+
|
7
|
+
|
8
|
+
def parse_mapping(mapping: str) -> Tuple[str, str]:
|
9
|
+
"""Parse a mapping string in the format 'name=value'.
|
10
|
+
|
11
|
+
Args:
|
12
|
+
mapping: Mapping string in format 'name=value'
|
13
|
+
|
14
|
+
Returns:
|
15
|
+
Tuple of (name, value)
|
16
|
+
|
17
|
+
Raises:
|
18
|
+
ValueError: If mapping format is invalid
|
19
|
+
VariableNameError: If name part is empty
|
20
|
+
VariableValueError: If value part is empty
|
21
|
+
"""
|
22
|
+
if not mapping or "=" not in mapping:
|
23
|
+
raise ValueError("Invalid mapping format")
|
24
|
+
|
25
|
+
name, value = mapping.split("=", 1)
|
26
|
+
if not name:
|
27
|
+
raise VariableNameError("Empty name in mapping")
|
28
|
+
if not value:
|
29
|
+
raise VariableValueError("Empty value in mapping")
|
30
|
+
|
31
|
+
return name, value
|
ostruct/py.typed
ADDED
File without changes
|