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.
@@ -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