ostruct-cli 0.4.0__py3-none-any.whl → 0.5.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.
@@ -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
@@ -0,0 +1,43 @@
1
+ """Token estimation utilities."""
2
+
3
+ from typing import Any, Dict, List, Union
4
+
5
+ import tiktoken
6
+
7
+
8
+ def estimate_tokens_with_encoding(
9
+ messages: Union[str, Dict[str, str], List[Dict[str, str]]],
10
+ model: str,
11
+ encoder: Any = None,
12
+ ) -> int:
13
+ """Estimate the number of tokens in a chat completion.
14
+
15
+ Args:
16
+ messages: Message content - can be string, single message dict, or list of messages
17
+ model: Model name
18
+ encoder: Optional tiktoken encoder for testing
19
+
20
+ Returns:
21
+ int: Estimated token count
22
+ """
23
+ if encoder is None:
24
+ # Use o200k_base for gpt-4o and o1 models
25
+ if model.startswith(("gpt-4o", "o1", "o3")):
26
+ encoder = tiktoken.get_encoding("o200k_base")
27
+ else:
28
+ encoder = tiktoken.get_encoding("cl100k_base")
29
+
30
+ if isinstance(messages, str):
31
+ return len(encoder.encode(messages))
32
+ elif isinstance(messages, dict):
33
+ return len(encoder.encode(str(messages.get("content", ""))))
34
+ else:
35
+ num_tokens = 0
36
+ for message in messages:
37
+ num_tokens += 4 # message overhead
38
+ for key, value in message.items():
39
+ num_tokens += len(encoder.encode(str(value)))
40
+ if key == "name":
41
+ num_tokens -= 1 # role is omitted
42
+ num_tokens += 2 # reply priming
43
+ return num_tokens
@@ -0,0 +1,109 @@
1
+ """Validators for CLI options and arguments."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, List, Optional, Tuple, Union
6
+
7
+ import click
8
+
9
+ from .errors import InvalidJSONError, VariableNameError
10
+
11
+
12
+ def validate_name_path_pair(
13
+ ctx: click.Context,
14
+ param: click.Parameter,
15
+ value: List[Tuple[str, Union[str, Path]]],
16
+ ) -> List[Tuple[str, Union[str, Path]]]:
17
+ """Validate name/path pairs for files and directories.
18
+
19
+ Args:
20
+ ctx: Click context
21
+ param: Click parameter
22
+ value: List of (name, path) tuples
23
+
24
+ Returns:
25
+ List of validated (name, Path) tuples
26
+
27
+ Raises:
28
+ click.BadParameter: If validation fails
29
+ """
30
+ if not value:
31
+ return value
32
+
33
+ result: List[Tuple[str, Union[str, Path]]] = []
34
+ for name, path in value:
35
+ if not name.isidentifier():
36
+ raise click.BadParameter(f"Invalid variable name: {name}")
37
+ result.append((name, Path(path)))
38
+ return result
39
+
40
+
41
+ def validate_variable(
42
+ ctx: click.Context, param: click.Parameter, value: Optional[List[str]]
43
+ ) -> Optional[List[Tuple[str, str]]]:
44
+ """Validate name=value format for simple variables.
45
+
46
+ Args:
47
+ ctx: Click context
48
+ param: Click parameter
49
+ value: List of "name=value" strings
50
+
51
+ Returns:
52
+ List of validated (name, value) tuples
53
+
54
+ Raises:
55
+ click.BadParameter: If validation fails
56
+ """
57
+ if not value:
58
+ return None
59
+
60
+ result = []
61
+ for var in value:
62
+ if "=" not in var:
63
+ raise click.BadParameter(
64
+ f"Variable must be in format name=value: {var}"
65
+ )
66
+ name, val = var.split("=", 1)
67
+ if not name.isidentifier():
68
+ raise click.BadParameter(f"Invalid variable name: {name}")
69
+ result.append((name, val))
70
+ return result
71
+
72
+
73
+ def validate_json_variable(
74
+ ctx: click.Context, param: click.Parameter, value: Optional[List[str]]
75
+ ) -> Optional[List[Tuple[str, Any]]]:
76
+ """Validate JSON variable format.
77
+
78
+ Args:
79
+ ctx: Click context
80
+ param: Click parameter
81
+ value: List of "name=json_string" values
82
+
83
+ Returns:
84
+ List of validated (name, parsed_json) tuples
85
+
86
+ Raises:
87
+ click.BadParameter: If validation fails
88
+ """
89
+ if not value:
90
+ return None
91
+
92
+ result = []
93
+ for var in value:
94
+ if "=" not in var:
95
+ raise InvalidJSONError(
96
+ f'JSON variable must be in format name=\'{"json":"value"}\': {var}'
97
+ )
98
+ name, json_str = var.split("=", 1)
99
+ if not name.isidentifier():
100
+ raise VariableNameError(f"Invalid variable name: {name}")
101
+ try:
102
+ json_value = json.loads(json_str)
103
+ result.append((name, json_value))
104
+ except json.JSONDecodeError as e:
105
+ raise InvalidJSONError(
106
+ f"Invalid JSON value for variable {name!r}: {json_str!r}",
107
+ context={"variable_name": name},
108
+ ) from e
109
+ return result