ostruct-cli 0.4.0__py3-none-any.whl → 0.6.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/cli/base_errors.py +183 -0
- ostruct/cli/cli.py +879 -592
- ostruct/cli/click_options.py +320 -202
- ostruct/cli/errors.py +273 -134
- ostruct/cli/exit_codes.py +18 -0
- ostruct/cli/file_info.py +30 -14
- ostruct/cli/file_list.py +4 -10
- ostruct/cli/file_utils.py +43 -35
- ostruct/cli/path_utils.py +32 -4
- ostruct/cli/schema_validation.py +213 -0
- ostruct/cli/security/allowed_checker.py +8 -0
- ostruct/cli/security/base.py +46 -0
- ostruct/cli/security/errors.py +83 -103
- ostruct/cli/security/security_manager.py +22 -9
- ostruct/cli/serialization.py +25 -0
- ostruct/cli/template_filters.py +5 -3
- ostruct/cli/template_rendering.py +46 -22
- ostruct/cli/template_utils.py +12 -4
- ostruct/cli/template_validation.py +26 -8
- ostruct/cli/token_utils.py +43 -0
- ostruct/cli/validators.py +109 -0
- ostruct_cli-0.6.0.dist-info/METADATA +404 -0
- ostruct_cli-0.6.0.dist-info/RECORD +43 -0
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.6.0.dist-info}/WHEEL +1 -1
- ostruct_cli-0.4.0.dist-info/METADATA +0 -186
- ostruct_cli-0.4.0.dist-info/RECORD +0 -36
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.6.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.4.0.dist-info → ostruct_cli-0.6.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/security/errors.py
CHANGED
@@ -6,121 +6,64 @@ the security modules.
|
|
6
6
|
|
7
7
|
from typing import Any, Dict, List, Optional
|
8
8
|
|
9
|
+
from .base import SecurityErrorBase
|
9
10
|
|
10
|
-
class PathSecurityError(Exception):
|
11
|
-
"""Base exception for security-related errors.
|
12
11
|
|
13
|
-
|
14
|
-
|
15
|
-
"""
|
12
|
+
class PathSecurityError(SecurityErrorBase):
|
13
|
+
"""Security error for path-related issues."""
|
16
14
|
|
17
15
|
def __init__(
|
18
16
|
self,
|
19
17
|
message: str,
|
20
|
-
path: str =
|
18
|
+
path: Optional[str] = None,
|
21
19
|
context: Optional[Dict[str, Any]] = None,
|
20
|
+
details: Optional[str] = None,
|
22
21
|
error_logged: bool = False,
|
22
|
+
wrapped: bool = False,
|
23
23
|
) -> None:
|
24
24
|
"""Initialize the error.
|
25
25
|
|
26
26
|
Args:
|
27
27
|
message: The error message.
|
28
28
|
path: The path that caused the error.
|
29
|
-
context: Additional context
|
30
|
-
|
29
|
+
context: Additional context for the error.
|
30
|
+
details: Detailed explanation of the error.
|
31
|
+
error_logged: Whether the error has been logged.
|
32
|
+
wrapped: Whether this is a wrapped error.
|
31
33
|
"""
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
f"Original path: {self.context['original_path']}\n"
|
51
|
-
f"Expanded path: {self.context['expanded_path']}"
|
52
|
-
)
|
53
|
-
if "base_dir" in self.context:
|
54
|
-
msg = f"{msg}\nBase directory: {self.context['base_dir']}"
|
55
|
-
if "allowed_dirs" in self.context:
|
56
|
-
msg = f"{msg}\nAllowed directories: {self.context['allowed_dirs']!r}"
|
57
|
-
|
58
|
-
return msg
|
34
|
+
if context is None:
|
35
|
+
context = {}
|
36
|
+
if path is not None:
|
37
|
+
context["path"] = path
|
38
|
+
if details is None:
|
39
|
+
details = "The specified path violates security constraints"
|
40
|
+
context["troubleshooting"] = [
|
41
|
+
"Check if the path is within allowed directories",
|
42
|
+
"Use --allowed-dir to specify additional allowed directories",
|
43
|
+
"Verify path permissions",
|
44
|
+
]
|
45
|
+
self._wrapped = wrapped
|
46
|
+
super().__init__(
|
47
|
+
message,
|
48
|
+
context=context,
|
49
|
+
details=details,
|
50
|
+
has_been_logged=error_logged,
|
51
|
+
)
|
59
52
|
|
60
53
|
@property
|
61
|
-
def
|
62
|
-
"""
|
63
|
-
return self.
|
64
|
-
|
65
|
-
@has_been_logged.setter
|
66
|
-
def has_been_logged(self, value: bool) -> None:
|
67
|
-
"""Set whether this error has been logged."""
|
68
|
-
self._error_logged = value
|
54
|
+
def error_logged(self) -> bool:
|
55
|
+
"""Alias for has_been_logged for backward compatibility."""
|
56
|
+
return self.has_been_logged
|
69
57
|
|
70
58
|
@property
|
71
59
|
def wrapped(self) -> bool:
|
72
|
-
"""Whether this
|
60
|
+
"""Whether this is a wrapped error."""
|
73
61
|
return self._wrapped
|
74
62
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
base_dir: str,
|
80
|
-
allowed_dirs: List[str],
|
81
|
-
) -> str:
|
82
|
-
"""Format the error message with additional context.
|
83
|
-
|
84
|
-
Args:
|
85
|
-
original_path: The original path that caused the error
|
86
|
-
expanded_path: The expanded/absolute path
|
87
|
-
base_dir: The base directory for security checks
|
88
|
-
allowed_dirs: List of allowed directories
|
89
|
-
|
90
|
-
Returns:
|
91
|
-
A formatted error message with context
|
92
|
-
"""
|
93
|
-
lines = [
|
94
|
-
str(self),
|
95
|
-
f"Original path: {original_path}",
|
96
|
-
f"Expanded path: {expanded_path}",
|
97
|
-
f"Base directory: {base_dir}",
|
98
|
-
f"Allowed directories: {allowed_dirs}",
|
99
|
-
"Use --allowed-dir to add more allowed directories",
|
100
|
-
]
|
101
|
-
return "\n".join(lines)
|
102
|
-
|
103
|
-
@classmethod
|
104
|
-
def wrap_error(
|
105
|
-
cls, message: str, original: "PathSecurityError"
|
106
|
-
) -> "PathSecurityError":
|
107
|
-
"""Wrap an existing error with additional context.
|
108
|
-
|
109
|
-
Args:
|
110
|
-
message: The new error message
|
111
|
-
original: The original error to wrap
|
112
|
-
|
113
|
-
Returns:
|
114
|
-
A new PathSecurityError instance wrapping the original
|
115
|
-
"""
|
116
|
-
wrapped = cls(
|
117
|
-
f"{message}: {str(original)}",
|
118
|
-
path=original.path,
|
119
|
-
context=original.context,
|
120
|
-
error_logged=original.has_been_logged,
|
121
|
-
)
|
122
|
-
wrapped._wrapped = True
|
123
|
-
return wrapped
|
63
|
+
@property
|
64
|
+
def details(self) -> str:
|
65
|
+
"""Get the detailed explanation of the error."""
|
66
|
+
return self.details
|
124
67
|
|
125
68
|
@classmethod
|
126
69
|
def from_expanded_paths(
|
@@ -131,32 +74,69 @@ class PathSecurityError(Exception):
|
|
131
74
|
allowed_dirs: List[str],
|
132
75
|
error_logged: bool = False,
|
133
76
|
) -> "PathSecurityError":
|
134
|
-
"""Create an error
|
77
|
+
"""Create an error from expanded paths.
|
135
78
|
|
136
79
|
Args:
|
137
|
-
original_path: The original path
|
138
|
-
expanded_path: The expanded
|
139
|
-
base_dir: The base directory
|
140
|
-
allowed_dirs: List of allowed directories
|
141
|
-
error_logged: Whether
|
80
|
+
original_path: The original path.
|
81
|
+
expanded_path: The expanded path.
|
82
|
+
base_dir: The base directory.
|
83
|
+
allowed_dirs: List of allowed directories.
|
84
|
+
error_logged: Whether the error has been logged.
|
142
85
|
|
143
86
|
Returns:
|
144
|
-
A new PathSecurityError instance
|
87
|
+
A new PathSecurityError instance.
|
145
88
|
"""
|
146
|
-
message = f"Path '{original_path}' is outside the base directory and not in allowed directories"
|
147
89
|
context = {
|
148
90
|
"original_path": original_path,
|
149
91
|
"expanded_path": expanded_path,
|
150
92
|
"base_dir": base_dir,
|
151
93
|
"allowed_dirs": allowed_dirs,
|
94
|
+
"reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
|
95
|
+
"troubleshooting": [
|
96
|
+
"Check if the path is within allowed directories",
|
97
|
+
f"Ensure the path is within base directory: {base_dir}",
|
98
|
+
f"Current allowed directories: {', '.join(allowed_dirs)}",
|
99
|
+
],
|
152
100
|
}
|
153
101
|
return cls(
|
154
|
-
|
155
|
-
path=original_path,
|
102
|
+
"Access denied",
|
156
103
|
context=context,
|
104
|
+
details="Path is outside allowed directories",
|
157
105
|
error_logged=error_logged,
|
158
106
|
)
|
159
107
|
|
108
|
+
@classmethod
|
109
|
+
def wrap_error(
|
110
|
+
cls, message: str, original_error: Exception
|
111
|
+
) -> "PathSecurityError":
|
112
|
+
"""Wrap another error with a security error.
|
113
|
+
|
114
|
+
Args:
|
115
|
+
message: The security error message.
|
116
|
+
original_error: The original error to wrap.
|
117
|
+
|
118
|
+
Returns:
|
119
|
+
A new PathSecurityError instance.
|
120
|
+
"""
|
121
|
+
context = {
|
122
|
+
"wrapped_error": original_error.__class__.__name__,
|
123
|
+
"original_message": str(original_error),
|
124
|
+
"wrapped": True,
|
125
|
+
"troubleshooting": [
|
126
|
+
"Check if the path is within allowed directories",
|
127
|
+
"Verify path permissions",
|
128
|
+
"Check if the original error has been resolved",
|
129
|
+
],
|
130
|
+
}
|
131
|
+
if hasattr(original_error, "context"):
|
132
|
+
context.update(original_error.context)
|
133
|
+
return cls(
|
134
|
+
message,
|
135
|
+
context=context,
|
136
|
+
wrapped=True,
|
137
|
+
error_logged=getattr(original_error, "error_logged", False),
|
138
|
+
)
|
139
|
+
|
160
140
|
|
161
141
|
class DirectoryNotFoundError(PathSecurityError):
|
162
142
|
"""Raised when a directory that is expected to exist does not."""
|
@@ -15,6 +15,8 @@ from contextlib import contextmanager
|
|
15
15
|
from pathlib import Path
|
16
16
|
from typing import Generator, List, Optional, Union
|
17
17
|
|
18
|
+
from ostruct.cli.errors import OstructFileNotFoundError
|
19
|
+
|
18
20
|
from .allowed_checker import is_path_in_allowed_dirs
|
19
21
|
from .case_manager import CaseManager
|
20
22
|
from .errors import (
|
@@ -200,24 +202,32 @@ class SecurityManager:
|
|
200
202
|
PathSecurityError: If the path fails security validation
|
201
203
|
FileNotFoundError: If the file doesn't exist (only checked after security validation)
|
202
204
|
"""
|
205
|
+
logger.debug("Validating path: %s", path)
|
206
|
+
|
203
207
|
# First normalize the path
|
204
208
|
norm_path = normalize_path(path)
|
209
|
+
logger.debug("Normalized path: %s", norm_path)
|
205
210
|
|
206
211
|
# Handle symlinks first - delegate to symlink_resolver
|
207
212
|
if norm_path.is_symlink():
|
213
|
+
logger.debug("Path is a symlink, resolving: %s", norm_path)
|
208
214
|
try:
|
209
|
-
|
215
|
+
resolved = _resolve_symlink(
|
210
216
|
norm_path,
|
211
217
|
self._max_symlink_depth,
|
212
218
|
self._allowed_dirs,
|
213
219
|
)
|
220
|
+
logger.debug("Resolved symlink to: %s", resolved)
|
221
|
+
return resolved
|
214
222
|
except RuntimeError as e:
|
215
223
|
if "Symlink loop" in str(e):
|
224
|
+
logger.error("Symlink loop detected: %s", path)
|
216
225
|
raise PathSecurityError(
|
217
226
|
"Symlink security violation: loop detected",
|
218
227
|
path=str(path),
|
219
228
|
context={"reason": SecurityErrorReasons.SYMLINK_LOOP},
|
220
229
|
) from e
|
230
|
+
logger.error("Failed to resolve symlink: %s - %s", path, e)
|
221
231
|
raise PathSecurityError(
|
222
232
|
f"Symlink security violation: failed to resolve symlink - {e}",
|
223
233
|
path=str(path),
|
@@ -225,10 +235,13 @@ class SecurityManager:
|
|
225
235
|
) from e
|
226
236
|
|
227
237
|
# For non-symlinks, just check if the normalized path is allowed
|
238
|
+
logger.debug("Checking if path is allowed: %s", norm_path)
|
228
239
|
if not self.is_path_allowed(norm_path):
|
229
240
|
logger.error(
|
230
|
-
"Security violation: Path %s is outside allowed directories",
|
241
|
+
"Security violation: Path %s is outside allowed directories (base_dir=%s, allowed_dirs=%s)",
|
231
242
|
path,
|
243
|
+
self._base_dir,
|
244
|
+
self._allowed_dirs,
|
232
245
|
)
|
233
246
|
raise PathSecurityError(
|
234
247
|
(
|
@@ -244,12 +257,12 @@ class SecurityManager:
|
|
244
257
|
)
|
245
258
|
|
246
259
|
# Only check existence after security validation passes
|
260
|
+
logger.debug("Checking if path exists: %s", norm_path)
|
247
261
|
if not norm_path.exists():
|
248
262
|
logger.debug("Path allowed but not found: %s", norm_path)
|
249
|
-
raise
|
250
|
-
f"File not found: {os.path.basename(str(path))}"
|
251
|
-
)
|
263
|
+
raise OstructFileNotFoundError(str(path))
|
252
264
|
|
265
|
+
logger.debug("Path validation successful: %s", norm_path)
|
253
266
|
return norm_path
|
254
267
|
|
255
268
|
def resolve_path(self, path: Union[str, Path]) -> Path:
|
@@ -275,7 +288,7 @@ class SecurityManager:
|
|
275
288
|
if self._allow_temp_paths and self.is_temp_path(norm_path):
|
276
289
|
logger.debug("Allowing temp path: %s", norm_path)
|
277
290
|
if not norm_path.exists():
|
278
|
-
raise
|
291
|
+
raise OstructFileNotFoundError(f"File not found: {path}")
|
279
292
|
return norm_path
|
280
293
|
|
281
294
|
# Handle symlinks with security checks
|
@@ -296,7 +309,7 @@ class SecurityManager:
|
|
296
309
|
elif reason == SecurityErrorReasons.SYMLINK_BROKEN:
|
297
310
|
msg = f"Broken symlink: {e.context['source']} -> {e.context['target']}"
|
298
311
|
logger.debug(msg)
|
299
|
-
raise
|
312
|
+
raise OstructFileNotFoundError(msg) from e
|
300
313
|
# Any other security errors propagate unchanged
|
301
314
|
raise
|
302
315
|
|
@@ -318,12 +331,12 @@ class SecurityManager:
|
|
318
331
|
|
319
332
|
# Only check existence after security validation
|
320
333
|
if not norm_path.exists():
|
321
|
-
raise
|
334
|
+
raise OstructFileNotFoundError(f"File not found: {path}")
|
322
335
|
|
323
336
|
return norm_path
|
324
337
|
|
325
338
|
except OSError as e:
|
326
|
-
if isinstance(e,
|
339
|
+
if isinstance(e, OstructFileNotFoundError):
|
327
340
|
raise
|
328
341
|
logger.error("Error resolving path: %s - %s", path, e)
|
329
342
|
raise PathSecurityError(
|
@@ -0,0 +1,25 @@
|
|
1
|
+
"""Serialization utilities for CLI logging."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
from typing import Any, Dict
|
5
|
+
|
6
|
+
|
7
|
+
class LogSerializer:
|
8
|
+
"""Utility class for serializing log data."""
|
9
|
+
|
10
|
+
@staticmethod
|
11
|
+
def serialize_log_extra(extra: Dict[str, Any]) -> str:
|
12
|
+
"""Serialize extra log data to a formatted string.
|
13
|
+
|
14
|
+
Args:
|
15
|
+
extra: Dictionary of extra log data
|
16
|
+
|
17
|
+
Returns:
|
18
|
+
Formatted string representation of the extra data
|
19
|
+
"""
|
20
|
+
try:
|
21
|
+
# Try to serialize with nice formatting
|
22
|
+
return json.dumps(extra, indent=2, default=str)
|
23
|
+
except Exception:
|
24
|
+
# Fall back to basic string representation if JSON fails
|
25
|
+
return str(extra)
|
ostruct/cli/template_filters.py
CHANGED
@@ -10,7 +10,7 @@ from collections import Counter
|
|
10
10
|
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
|
11
11
|
|
12
12
|
import tiktoken
|
13
|
-
from jinja2 import Environment
|
13
|
+
from jinja2 import Environment, pass_context
|
14
14
|
from pygments import highlight
|
15
15
|
from pygments.formatters import HtmlFormatter, NullFormatter, TerminalFormatter
|
16
16
|
from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer
|
@@ -178,10 +178,12 @@ def format_error(e: Exception) -> str:
|
|
178
178
|
return f"{type(e).__name__}: {str(e)}"
|
179
179
|
|
180
180
|
|
181
|
-
|
181
|
+
@pass_context
|
182
|
+
def estimate_tokens(context: Any, text: str) -> int:
|
182
183
|
"""Estimate number of tokens in text."""
|
183
184
|
try:
|
184
|
-
encoding
|
185
|
+
# Use o200k_base encoding for token estimation
|
186
|
+
encoding = tiktoken.get_encoding("o200k_base")
|
185
187
|
return len(encoding.encode(str(text)))
|
186
188
|
except Exception as e:
|
187
189
|
logger.warning(f"Failed to estimate tokens: {e}")
|
@@ -61,8 +61,9 @@ from typing import Any, Dict, List, Optional, Union
|
|
61
61
|
import jinja2
|
62
62
|
from jinja2 import Environment
|
63
63
|
|
64
|
-
from .errors import TemplateValidationError
|
64
|
+
from .errors import TaskTemplateVariableError, TemplateValidationError
|
65
65
|
from .file_utils import FileInfo
|
66
|
+
from .progress import ProgressContext
|
66
67
|
from .template_env import create_jinja_env
|
67
68
|
from .template_schema import DotDict, StdinProxy
|
68
69
|
|
@@ -92,23 +93,23 @@ TemplateContextValue = Union[
|
|
92
93
|
def render_template(
|
93
94
|
template_str: str,
|
94
95
|
context: Dict[str, Any],
|
95
|
-
|
96
|
-
|
96
|
+
env: Optional[Environment] = None,
|
97
|
+
progress: Optional[ProgressContext] = None,
|
97
98
|
) -> str:
|
98
|
-
"""Render a
|
99
|
+
"""Render a template with the given context.
|
99
100
|
|
100
101
|
Args:
|
101
|
-
template_str:
|
102
|
-
context:
|
103
|
-
|
104
|
-
|
102
|
+
template_str: Template string to render
|
103
|
+
context: Context dictionary for template variables
|
104
|
+
env: Optional Jinja2 environment to use
|
105
|
+
progress: Optional progress bar to update
|
105
106
|
|
106
107
|
Returns:
|
107
|
-
|
108
|
+
str: The rendered template string
|
108
109
|
|
109
110
|
Raises:
|
110
|
-
|
111
|
-
|
111
|
+
TaskTemplateVariableError: If template variables are undefined
|
112
|
+
TemplateValidationError: If template rendering fails for other reasons
|
112
113
|
"""
|
113
114
|
from .progress import ( # Import here to avoid circular dependency
|
114
115
|
ProgressContext,
|
@@ -116,16 +117,14 @@ def render_template(
|
|
116
117
|
|
117
118
|
with ProgressContext(
|
118
119
|
description="Rendering task template",
|
119
|
-
level="basic" if
|
120
|
+
level="basic" if progress else "none",
|
120
121
|
) as progress:
|
121
122
|
try:
|
122
123
|
if progress:
|
123
124
|
progress.update(1) # Update progress for setup
|
124
125
|
|
125
|
-
if
|
126
|
-
|
127
|
-
loader=jinja2.FileSystemLoader(".")
|
128
|
-
)
|
126
|
+
if env is None:
|
127
|
+
env = create_jinja_env(loader=jinja2.FileSystemLoader("."))
|
129
128
|
|
130
129
|
logger.debug("=== Raw Input ===")
|
131
130
|
logger.debug(
|
@@ -154,7 +153,7 @@ def render_template(
|
|
154
153
|
# Wrap JSON variables in DotDict and handle special cases
|
155
154
|
wrapped_context: Dict[str, TemplateContextValue] = {}
|
156
155
|
for key, value in context.items():
|
157
|
-
if isinstance(value, dict):
|
156
|
+
if isinstance(value, dict) and not isinstance(value, DotDict):
|
158
157
|
wrapped_context[key] = DotDict(value)
|
159
158
|
else:
|
160
159
|
wrapped_context[key] = value
|
@@ -188,7 +187,7 @@ def render_template(
|
|
188
187
|
f"Task template file not found: {template_str}"
|
189
188
|
)
|
190
189
|
try:
|
191
|
-
template =
|
190
|
+
template = env.get_template(template_str)
|
192
191
|
except jinja2.TemplateNotFound as e:
|
193
192
|
raise TemplateValidationError(
|
194
193
|
f"Task template file not found: {e.name}"
|
@@ -199,10 +198,10 @@ def render_template(
|
|
199
198
|
template_str,
|
200
199
|
)
|
201
200
|
try:
|
202
|
-
template =
|
201
|
+
template = env.from_string(template_str)
|
203
202
|
|
204
203
|
# Add debug log for loop rendering
|
205
|
-
def debug_file_render(f: FileInfo) ->
|
204
|
+
def debug_file_render(f: FileInfo) -> Any:
|
206
205
|
logger.info("Rendering file: %s", f.path)
|
207
206
|
return ""
|
208
207
|
|
@@ -292,6 +291,10 @@ def render_template(
|
|
292
291
|
" %s: %s (%r)", key, type(value).__name__, value
|
293
292
|
)
|
294
293
|
result = template.render(**wrapped_context)
|
294
|
+
if not isinstance(result, str):
|
295
|
+
raise TemplateValidationError(
|
296
|
+
f"Template rendered to non-string type: {type(result)}"
|
297
|
+
)
|
295
298
|
logger.info(
|
296
299
|
"Template render result (first 100 chars): %r",
|
297
300
|
result[:100],
|
@@ -302,7 +305,17 @@ def render_template(
|
|
302
305
|
)
|
303
306
|
if progress:
|
304
307
|
progress.update(1)
|
305
|
-
return result
|
308
|
+
return result
|
309
|
+
except jinja2.UndefinedError as e:
|
310
|
+
# Extract variable name from error message
|
311
|
+
var_name = str(e).split("'")[1]
|
312
|
+
error_msg = (
|
313
|
+
f"Missing required template variable: {var_name}\n"
|
314
|
+
f"Available variables: {', '.join(sorted(context.keys()))}\n"
|
315
|
+
"To fix this, please provide the variable using:\n"
|
316
|
+
f" -V {var_name}='value'"
|
317
|
+
)
|
318
|
+
raise TaskTemplateVariableError(error_msg) from e
|
306
319
|
except (jinja2.TemplateError, Exception) as e:
|
307
320
|
logger.error("Template rendering failed: %s", str(e))
|
308
321
|
raise TemplateValidationError(
|
@@ -336,4 +349,15 @@ def render_template_file(
|
|
336
349
|
"""
|
337
350
|
with open(template_path, "r", encoding="utf-8") as f:
|
338
351
|
template_str = f.read()
|
339
|
-
|
352
|
+
|
353
|
+
# Create a progress context if enabled
|
354
|
+
progress = (
|
355
|
+
ProgressContext(
|
356
|
+
description="Rendering template file",
|
357
|
+
level="basic" if progress_enabled else "none",
|
358
|
+
)
|
359
|
+
if progress_enabled
|
360
|
+
else None
|
361
|
+
)
|
362
|
+
|
363
|
+
return render_template(template_str, context, jinja_env, progress)
|
ostruct/cli/template_utils.py
CHANGED
@@ -9,10 +9,11 @@ It re-exports the public APIs from specialized modules:
|
|
9
9
|
- template_io: File I/O operations and metadata extraction
|
10
10
|
"""
|
11
11
|
|
12
|
+
import logging
|
12
13
|
from typing import Any, Dict, List, Optional, Set
|
13
14
|
|
14
15
|
import jsonschema
|
15
|
-
from jinja2 import Environment, meta
|
16
|
+
from jinja2 import Environment, TemplateSyntaxError, meta
|
16
17
|
from jinja2.nodes import Node
|
17
18
|
|
18
19
|
from .errors import (
|
@@ -35,6 +36,8 @@ from .template_schema import (
|
|
35
36
|
)
|
36
37
|
from .template_validation import SafeUndefined, validate_template_placeholders
|
37
38
|
|
39
|
+
logger = logging.getLogger(__name__)
|
40
|
+
|
38
41
|
|
39
42
|
# Custom error classes
|
40
43
|
class TemplateMetadataError(TaskTemplateError):
|
@@ -118,8 +121,13 @@ def find_all_template_variables(
|
|
118
121
|
if env is None:
|
119
122
|
env = Environment(undefined=SafeUndefined)
|
120
123
|
|
121
|
-
|
122
|
-
|
124
|
+
try:
|
125
|
+
ast = env.parse(template)
|
126
|
+
except TemplateSyntaxError as e:
|
127
|
+
logger.error("Failed to parse template: %s", str(e))
|
128
|
+
return set() # Return empty set on parse error
|
129
|
+
|
130
|
+
variables: Set[str] = set()
|
123
131
|
|
124
132
|
# Find all variables in this template
|
125
133
|
variables = meta.find_undeclared_variables(ast)
|
@@ -252,7 +260,7 @@ def find_all_template_variables(
|
|
252
260
|
visit_nodes(node.else_)
|
253
261
|
|
254
262
|
visit_nodes(ast.body)
|
255
|
-
return variables
|
263
|
+
return variables
|
256
264
|
|
257
265
|
|
258
266
|
__all__ = [
|
@@ -67,7 +67,7 @@ from jinja2 import meta
|
|
67
67
|
from jinja2.nodes import For, Name, Node
|
68
68
|
|
69
69
|
from . import template_filters
|
70
|
-
from .errors import TemplateValidationError
|
70
|
+
from .errors import TaskTemplateVariableError, TemplateValidationError
|
71
71
|
from .template_env import create_jinja_env
|
72
72
|
from .template_schema import (
|
73
73
|
DictProxy,
|
@@ -160,7 +160,8 @@ def validate_template_placeholders(
|
|
160
160
|
template_context: Optional context to validate against
|
161
161
|
|
162
162
|
Raises:
|
163
|
-
|
163
|
+
TaskTemplateVariableError: If any variables are undefined
|
164
|
+
TemplateValidationError: If template validation fails for other reasons
|
164
165
|
"""
|
165
166
|
logger = logging.getLogger(__name__)
|
166
167
|
|
@@ -309,10 +310,15 @@ def validate_template_placeholders(
|
|
309
310
|
}
|
310
311
|
|
311
312
|
if missing:
|
312
|
-
|
313
|
-
|
314
|
-
f"
|
313
|
+
# Create a more user-friendly error message
|
314
|
+
error_msg = (
|
315
|
+
f"Missing required template variable(s): {', '.join(sorted(missing))}\n"
|
316
|
+
f"Available variables: {', '.join(sorted(available_vars))}\n"
|
317
|
+
"To fix this, please provide the missing variable(s) using:\n"
|
315
318
|
)
|
319
|
+
for var in sorted(missing):
|
320
|
+
error_msg += f" -V {var}='value'\n"
|
321
|
+
raise TaskTemplateVariableError(error_msg)
|
316
322
|
|
317
323
|
logger.debug(
|
318
324
|
"Before create_validation_context - available_vars type: %s, value: %s",
|
@@ -336,8 +342,17 @@ def validate_template_placeholders(
|
|
336
342
|
try:
|
337
343
|
env.from_string(template).render(validation_context)
|
338
344
|
except jinja2.UndefinedError as e:
|
339
|
-
# Convert Jinja2 undefined errors to
|
340
|
-
|
345
|
+
# Convert Jinja2 undefined errors to TaskTemplateVariableError with helpful message
|
346
|
+
var_name = str(e).split("'")[
|
347
|
+
1
|
348
|
+
] # Extract variable name from error message
|
349
|
+
error_msg = (
|
350
|
+
f"Missing required template variable: {var_name}\n"
|
351
|
+
f"Available variables: {', '.join(sorted(available_vars))}\n"
|
352
|
+
"To fix this, please provide the variable using:\n"
|
353
|
+
f" -V {var_name}='value'"
|
354
|
+
)
|
355
|
+
raise TaskTemplateVariableError(error_msg) from e
|
341
356
|
except ValueError as e:
|
342
357
|
# Convert validation errors from template_schema to TemplateValidationError
|
343
358
|
raise TemplateValidationError(str(e))
|
@@ -348,10 +363,13 @@ def validate_template_placeholders(
|
|
348
363
|
raise
|
349
364
|
|
350
365
|
except jinja2.TemplateSyntaxError as e:
|
351
|
-
# Convert Jinja2 syntax errors to
|
366
|
+
# Convert Jinja2 syntax errors to TemplateValidationError
|
352
367
|
raise TemplateValidationError(
|
353
368
|
f"Invalid task template syntax: {str(e)}"
|
354
369
|
)
|
370
|
+
except (TaskTemplateVariableError, TemplateValidationError):
|
371
|
+
# Re-raise these without wrapping
|
372
|
+
raise
|
355
373
|
except Exception as e:
|
356
374
|
logger.error("Unexpected error during template validation: %s", str(e))
|
357
375
|
raise
|