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.
@@ -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
- This class provides rich error information for security-related issues,
14
- including context and error wrapping capabilities.
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 about the error.
30
- error_logged: Whether this error has already been logged.
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
- super().__init__(message)
33
- self.path = path
34
- self.context = context or {}
35
- self._error_logged = error_logged
36
- self._wrapped = False
37
-
38
- def __str__(self) -> str:
39
- """Format the error message with context if available."""
40
- msg = super().__str__()
41
-
42
- # Add expanded path information if available
43
- if self.context:
44
- if (
45
- "original_path" in self.context
46
- and "expanded_path" in self.context
47
- ):
48
- msg = (
49
- f"{msg}\n"
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 has_been_logged(self) -> bool:
62
- """Whether this error has been logged."""
63
- return self._error_logged
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 error is wrapping another error."""
60
+ """Whether this is a wrapped error."""
73
61
  return self._wrapped
74
62
 
75
- def format_with_context(
76
- self,
77
- original_path: str,
78
- expanded_path: str,
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 instance with expanded path information.
77
+ """Create an error from expanded paths.
135
78
 
136
79
  Args:
137
- original_path: The original path that caused the error
138
- expanded_path: The expanded/absolute path
139
- base_dir: The base directory for security checks
140
- allowed_dirs: List of allowed directories
141
- error_logged: Whether this error has already been logged
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 with expanded path context
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
- message,
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
- return _resolve_symlink(
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 FileNotFoundError(
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 FileNotFoundError(f"File not found: {path}")
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 FileNotFoundError(msg) from e
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 FileNotFoundError(f"File not found: {path}")
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, FileNotFoundError):
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)
@@ -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
- def estimate_tokens(text: str) -> int:
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 = tiktoken.encoding_for_model("gpt-4")
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
- jinja_env: Optional[Environment] = None,
96
- progress_enabled: bool = True,
96
+ env: Optional[Environment] = None,
97
+ progress: Optional[ProgressContext] = None,
97
98
  ) -> str:
98
- """Render a task template with the given context.
99
+ """Render a template with the given context.
99
100
 
100
101
  Args:
101
- template_str: Task template string or path to task template file
102
- context: Task template variables
103
- jinja_env: Optional Jinja2 environment to use
104
- progress_enabled: Whether to show progress indicators
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
- Rendered task template string
108
+ str: The rendered template string
108
109
 
109
110
  Raises:
110
- TemplateValidationError: If task template cannot be loaded or rendered. The original error
111
- will be chained using `from` for proper error context.
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 progress_enabled else "none",
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 jinja_env is None:
126
- jinja_env = create_jinja_env(
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 = jinja_env.get_template(template_str)
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 = jinja_env.from_string(template_str)
201
+ template = env.from_string(template_str)
203
202
 
204
203
  # Add debug log for loop rendering
205
- def debug_file_render(f: FileInfo) -> str:
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 # type: ignore[no-any-return]
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
- return render_template(template_str, context, jinja_env, progress_enabled)
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)
@@ -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
- # Parse template
122
- ast = env.parse(template)
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 # type: ignore[no-any-return]
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
- TemplateValidationError: If any placeholders are invalid
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
- raise TemplateValidationError(
313
- f"Task template uses undefined variables: {', '.join(sorted(missing))}. "
314
- f"Available variables: {', '.join(sorted(available_vars))}"
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 our own ValueError
340
- raise TemplateValidationError(str(e))
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 our own ValueError
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