ostruct-cli 0.3.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.
Files changed (35) hide show
  1. ostruct/cli/base_errors.py +183 -0
  2. ostruct/cli/cli.py +830 -585
  3. ostruct/cli/click_options.py +338 -211
  4. ostruct/cli/errors.py +214 -227
  5. ostruct/cli/exit_codes.py +18 -0
  6. ostruct/cli/file_info.py +126 -69
  7. ostruct/cli/file_list.py +191 -72
  8. ostruct/cli/file_utils.py +132 -97
  9. ostruct/cli/path_utils.py +86 -77
  10. ostruct/cli/security/__init__.py +32 -0
  11. ostruct/cli/security/allowed_checker.py +55 -0
  12. ostruct/cli/security/base.py +46 -0
  13. ostruct/cli/security/case_manager.py +75 -0
  14. ostruct/cli/security/errors.py +164 -0
  15. ostruct/cli/security/normalization.py +161 -0
  16. ostruct/cli/security/safe_joiner.py +211 -0
  17. ostruct/cli/security/security_manager.py +366 -0
  18. ostruct/cli/security/symlink_resolver.py +483 -0
  19. ostruct/cli/security/types.py +108 -0
  20. ostruct/cli/security/windows_paths.py +404 -0
  21. ostruct/cli/serialization.py +25 -0
  22. ostruct/cli/template_filters.py +13 -8
  23. ostruct/cli/template_rendering.py +46 -22
  24. ostruct/cli/template_utils.py +12 -4
  25. ostruct/cli/template_validation.py +26 -8
  26. ostruct/cli/token_utils.py +43 -0
  27. ostruct/cli/validators.py +109 -0
  28. {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/METADATA +64 -24
  29. ostruct_cli-0.5.0.dist-info/RECORD +42 -0
  30. {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/WHEEL +1 -1
  31. ostruct/cli/security.py +0 -964
  32. ostruct/cli/security_types.py +0 -46
  33. ostruct_cli-0.3.0.dist-info/RECORD +0 -28
  34. {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/LICENSE +0 -0
  35. {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/errors.py CHANGED
@@ -1,47 +1,14 @@
1
1
  """Custom error classes for CLI error handling."""
2
2
 
3
- import os
4
- from pathlib import Path
5
- from typing import Any, Dict, List, Optional, TextIO, cast
3
+ import logging
4
+ from typing import Any, Dict, List, Optional
6
5
 
7
- import click
6
+ from .base_errors import CLIError, OstructFileNotFoundError
7
+ from .exit_codes import ExitCode
8
+ from .security.base import SecurityErrorBase
9
+ from .security.errors import SecurityErrorReasons
8
10
 
9
-
10
- class CLIError(click.ClickException):
11
- """Base class for all CLI errors."""
12
-
13
- def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
14
- super().__init__(message)
15
- self.context = context or {}
16
- self._has_been_logged = False # Use underscore for private attribute
17
-
18
- @property
19
- def has_been_logged(self) -> bool:
20
- """Whether this error has been logged."""
21
- return self._has_been_logged
22
-
23
- @has_been_logged.setter
24
- def has_been_logged(self, value: bool) -> None:
25
- """Set whether this error has been logged."""
26
- self._has_been_logged = value
27
-
28
- def show(self, file: Optional[TextIO] = None) -> None:
29
- """Show the error message with optional context."""
30
- if file is None:
31
- file = cast(TextIO, click.get_text_stream("stderr"))
32
-
33
- # Format message with context if available
34
- if self.context:
35
- context_str = "\n".join(
36
- f" {k}: {v}" for k, v in self.context.items()
37
- )
38
- click.secho(
39
- f"Error: {self.message}\nContext:\n{context_str}",
40
- fg="red",
41
- file=file,
42
- )
43
- else:
44
- click.secho(f"Error: {self.message}", fg="red", file=file)
11
+ logger = logging.getLogger(__name__)
45
12
 
46
13
 
47
14
  class VariableError(CLIError):
@@ -63,7 +30,7 @@ class VariableValueError(VariableError):
63
30
 
64
31
 
65
32
  class InvalidJSONError(CLIError):
66
- """Raised when JSON parsing fails for a variable value."""
33
+ """Error raised when JSON is invalid."""
67
34
 
68
35
  def __init__(
69
36
  self,
@@ -71,236 +38,191 @@ class InvalidJSONError(CLIError):
71
38
  source: Optional[str] = None,
72
39
  context: Optional[Dict[str, Any]] = None,
73
40
  ):
41
+ """Initialize invalid JSON error.
42
+
43
+ Args:
44
+ message: Error message
45
+ source: Source of invalid JSON
46
+ context: Additional context for the error
47
+ """
74
48
  context = context or {}
75
49
  if source:
76
50
  context["source"] = source
77
- super().__init__(message, context)
51
+ super().__init__(
52
+ message,
53
+ exit_code=ExitCode.DATA_ERROR,
54
+ context=context,
55
+ )
78
56
 
79
57
 
80
58
  class PathError(CLIError):
81
59
  """Base class for path-related errors."""
82
60
 
83
61
  def __init__(
84
- self, message: str, path: str, context: Optional[Dict[str, Any]] = None
62
+ self,
63
+ message: str,
64
+ path: str,
65
+ context: Optional[Dict[str, Any]] = None,
66
+ exit_code: int = ExitCode.FILE_ERROR,
85
67
  ):
86
68
  context = context or {}
87
69
  context["path"] = path
88
- super().__init__(message, context)
70
+ super().__init__(message, context=context, exit_code=exit_code)
89
71
 
90
72
 
91
- class FileNotFoundError(PathError):
92
- """Raised when a specified file does not exist."""
73
+ class FileReadError(PathError):
74
+ """Raised when a file cannot be read or decoded.
93
75
 
94
- def __init__(self, path: str, context: Optional[Dict[str, Any]] = None):
95
- # Use path directly as the message without prepending "File not found: "
96
- super().__init__(path, path, context)
76
+ This is a wrapper exception that preserves the original cause (FileNotFoundError,
77
+ UnicodeDecodeError, etc) while providing a consistent interface for error handling.
78
+ """
79
+
80
+ def __init__(
81
+ self, message: str, path: str, context: Optional[Dict[str, Any]] = None
82
+ ):
83
+ super().__init__(message, path, context)
97
84
 
98
85
 
99
86
  class DirectoryNotFoundError(PathError):
100
87
  """Raised when a specified directory does not exist."""
101
88
 
102
89
  def __init__(self, path: str, context: Optional[Dict[str, Any]] = None):
103
- # Use path directly as the message without prepending "Directory not found: "
104
- super().__init__(path, path, context)
105
-
90
+ context = context or {}
91
+ context.update(
92
+ {
93
+ "details": "The specified directory does not exist or cannot be accessed",
94
+ "troubleshooting": [
95
+ "Check if the directory exists",
96
+ "Verify the path spelling is correct",
97
+ "Check directory permissions",
98
+ "Ensure parent directories exist",
99
+ "Use --allowed-dir to specify additional allowed directories",
100
+ ],
101
+ }
102
+ )
103
+ super().__init__(
104
+ f"Directory not found: {path}", path=path, context=context
105
+ )
106
106
 
107
- class PathSecurityError(PathError):
108
- """Exception raised when file access is denied due to security constraints.
109
107
 
110
- Attributes:
111
- message: The error message with full context
112
- wrapped: Whether this error has been wrapped by another error
113
- """
108
+ class PathSecurityError(SecurityErrorBase):
109
+ """Error raised when a path violates security constraints."""
114
110
 
115
111
  def __init__(
116
112
  self,
117
113
  message: str,
118
114
  path: Optional[str] = None,
119
- context: Optional[Dict[str, Any]] = None,
120
115
  error_logged: bool = False,
121
- ):
122
- path = path or "unknown" # Provide default path if none given
116
+ wrapped: bool = False,
117
+ context: Optional[Dict[str, Any]] = None,
118
+ ) -> None:
119
+ """Initialize error.
120
+
121
+ Args:
122
+ message: Error message
123
+ path: Path that caused the error
124
+ error_logged: Whether error has been logged
125
+ wrapped: Whether this is a wrapped error
126
+ context: Additional error context
127
+ """
123
128
  context = context or {}
124
- context["has_been_logged"] = (
125
- error_logged # Store in context to match parent behavior
126
- )
127
- context["wrapped"] = False # Initialize wrapped state
128
- super().__init__(message, path, context)
129
-
130
- def __str__(self) -> str:
131
- """Return string representation with allowed directories if present."""
132
- base = super().__str__()
133
- if self.context and "allowed_dirs" in self.context:
134
- allowed = self.context["allowed_dirs"]
135
- if allowed: # Only add if there are actually allowed dirs
136
- return f"{base} (allowed directories: {', '.join(allowed)})"
137
- return base
138
-
139
- @property
140
- def has_been_logged(self) -> bool:
141
- """Whether this error has been logged."""
142
- return bool(self.context.get("has_been_logged", False))
129
+ if path is not None:
130
+ context["path"] = path
131
+ context.setdefault(
132
+ "details", "The specified path violates security constraints"
133
+ )
134
+ context.setdefault(
135
+ "troubleshooting",
136
+ [
137
+ "Check if the path is within allowed directories",
138
+ "Use --allowed-dir to specify additional allowed directories",
139
+ "Verify path permissions",
140
+ ],
141
+ )
143
142
 
144
- @has_been_logged.setter
145
- def has_been_logged(self, value: bool) -> None:
146
- """Set whether this error has been logged."""
147
- self.context["has_been_logged"] = value
143
+ super().__init__(message, context=context)
144
+ self._wrapped = wrapped
145
+ self._error_logged = error_logged
148
146
 
149
147
  @property
150
148
  def error_logged(self) -> bool:
151
- """Alias for has_been_logged for backward compatibility."""
152
- return self.has_been_logged
153
-
154
- @error_logged.setter
155
- def error_logged(self, value: bool) -> None:
156
- """Alias for has_been_logged for backward compatibility."""
157
- self.has_been_logged = value
149
+ """Whether this error has been logged."""
150
+ return self._error_logged
158
151
 
159
152
  @property
160
153
  def wrapped(self) -> bool:
161
- """Whether this error has been wrapped by another error."""
162
- return bool(self.context.get("wrapped", False))
163
-
164
- @wrapped.setter
165
- def wrapped(self, value: bool) -> None:
166
- """Set whether this error has been wrapped."""
167
- self.context["wrapped"] = value
168
-
169
- @staticmethod
170
- def _format_allowed_dirs(allowed_dirs: List[str]) -> str:
171
- """Format allowed directories as a list representation."""
172
- return f"[{', '.join(repr(d) for d in allowed_dirs)}]"
173
-
174
- @staticmethod
175
- def _create_error_message(
176
- path: str, base_dir: Optional[str] = None
177
- ) -> str:
178
- """Create a standardized error message."""
179
- if base_dir:
180
- rel_path = os.path.relpath(path, base_dir)
181
- return f"Access denied: {rel_path} is outside base directory and not in allowed directories"
182
- return f"Access denied: {path} is outside base directory and not in allowed directories"
154
+ """Whether this is a wrapped error."""
155
+ return self._wrapped
183
156
 
184
157
  @classmethod
185
158
  def from_expanded_paths(
186
159
  cls,
187
160
  original_path: str,
188
161
  expanded_path: str,
189
- base_dir: Optional[str] = None,
190
- allowed_dirs: Optional[List[str]] = None,
162
+ base_dir: str,
163
+ allowed_dirs: List[str],
191
164
  error_logged: bool = False,
192
165
  ) -> "PathSecurityError":
193
- """Create error with expanded path context."""
194
- message = f"Access denied: {original_path} is outside base directory and not in allowed directories"
195
- if expanded_path != original_path:
196
- message += f" (expanded to {expanded_path})"
197
-
166
+ """Create error with expanded path information.
167
+
168
+ Args:
169
+ original_path: Original path provided
170
+ expanded_path: Expanded absolute path
171
+ base_dir: Base directory
172
+ allowed_dirs: List of allowed directories
173
+ error_logged: Whether error has been logged
174
+
175
+ Returns:
176
+ PathSecurityError instance
177
+ """
198
178
  context = {
199
179
  "original_path": original_path,
200
180
  "expanded_path": expanded_path,
201
- "has_been_logged": error_logged,
181
+ "base_dir": base_dir,
182
+ "allowed_dirs": allowed_dirs,
183
+ "reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
184
+ "details": "The path resolves to a location outside the allowed directories",
185
+ "troubleshooting": [
186
+ f"Ensure path is within base directory: {base_dir}",
187
+ "Use --allowed-dir to specify additional allowed directories",
188
+ f"Current allowed directories: {', '.join(allowed_dirs)}",
189
+ ],
202
190
  }
203
- if base_dir:
204
- context["base_dir"] = base_dir
205
- if allowed_dirs:
206
- context["allowed_dirs"] = allowed_dirs
207
-
208
- # Format full message with all context
209
- parts = [message]
210
- if base_dir:
211
- parts.append(f"Base directory: {base_dir}")
212
- if allowed_dirs:
213
- parts.append(
214
- f"Allowed directories: {cls._format_allowed_dirs(allowed_dirs)}"
215
- )
216
- parts.append(
217
- "Use --allowed-dir to specify additional allowed directories"
218
- )
219
191
 
220
192
  return cls(
221
- "\n".join(parts),
193
+ f"Access denied: {original_path!r} resolves to {expanded_path!r} which is "
194
+ f"outside base directory {base_dir!r}",
222
195
  path=original_path,
223
- context=context,
224
196
  error_logged=error_logged,
197
+ context=context,
225
198
  )
226
199
 
227
200
  @classmethod
228
- def access_denied(
229
- cls,
230
- path: Path,
231
- reason: Optional[str] = None,
232
- error_logged: bool = False,
233
- ) -> "PathSecurityError":
234
- """Create access denied error."""
235
- msg = f"Access denied: {path}"
236
- if reason:
237
- msg += f" - {reason}"
238
- msg += " is outside base directory and not in allowed directories"
239
- return cls(msg, path=str(path), error_logged=error_logged)
201
+ def wrap_error(cls, msg: str, original: Exception) -> "PathSecurityError":
202
+ """Wrap an existing error with additional context.
240
203
 
241
- @classmethod
242
- def outside_allowed(
243
- cls,
244
- path: Path,
245
- base_dir: Optional[Path] = None,
246
- error_logged: bool = False,
247
- ) -> "PathSecurityError":
248
- """Create error for path outside allowed directories."""
249
- msg = f"Access denied: {path} is outside base directory and not in allowed directories"
250
- context = {}
251
- if base_dir:
252
- context["base_directory"] = str(base_dir)
253
- return cls(
254
- msg, path=str(path), context=context, error_logged=error_logged
255
- )
204
+ Args:
205
+ msg: New error message
206
+ original: Original error to wrap
256
207
 
257
- @classmethod
258
- def traversal_attempt(
259
- cls, path: Path, error_logged: bool = False
260
- ) -> "PathSecurityError":
261
- """Create error for directory traversal attempt."""
262
- msg = f"Access denied: {path} - directory traversal not allowed"
263
- return cls(msg, path=str(path), error_logged=error_logged)
208
+ Returns:
209
+ New PathSecurityError instance
210
+ """
211
+ context = {
212
+ "wrapped_error": type(original).__name__,
213
+ "original_message": str(original),
214
+ }
264
215
 
265
- def format_with_context(
266
- self,
267
- original_path: Optional[str] = None,
268
- expanded_path: Optional[str] = None,
269
- base_dir: Optional[str] = None,
270
- allowed_dirs: Optional[List[str]] = None,
271
- ) -> str:
272
- """Format error message with additional context."""
273
- parts = [self.message]
274
- if original_path and expanded_path and original_path != expanded_path:
275
- parts.append(f"Original path: {original_path}")
276
- parts.append(f"Expanded path: {expanded_path}")
277
- if base_dir:
278
- parts.append(f"Base directory: {base_dir}")
279
- if allowed_dirs:
280
- parts.append(
281
- f"Allowed directories: {self._format_allowed_dirs(allowed_dirs)}"
282
- )
283
- parts.append(
284
- "Use --allowed-dir to specify additional allowed directories"
285
- )
286
- return "\n".join(parts)
216
+ if hasattr(original, "context"):
217
+ context.update(original.context)
287
218
 
288
- @classmethod
289
- def wrap_error(
290
- cls, context: str, original: "PathSecurityError"
291
- ) -> "PathSecurityError":
292
- """Wrap an error with additional context while preserving attributes."""
293
- message = f"{context}: {original.message}"
294
- new_context = original.context.copy()
295
- new_context["wrapped"] = True # Mark as wrapped
296
- error = cls(
297
- message,
298
- path=original.context.get("path", "unknown"),
299
- context=new_context,
300
- error_logged=original.has_been_logged,
219
+ return cls(
220
+ f"{msg}: {str(original)}",
221
+ path=getattr(original, "path", None),
222
+ error_logged=getattr(original, "error_logged", False),
223
+ wrapped=True,
224
+ context=context,
301
225
  )
302
- error.wrapped = True # Ensure wrapped is set through the property
303
- return error
304
226
 
305
227
 
306
228
  class TaskTemplateError(CLIError):
@@ -318,7 +240,22 @@ class TaskTemplateSyntaxError(TaskTemplateError):
318
240
  class TaskTemplateVariableError(TaskTemplateError):
319
241
  """Raised when a task template uses undefined variables."""
320
242
 
321
- pass
243
+ def __init__(
244
+ self,
245
+ message: str,
246
+ context: Optional[Dict[str, Any]] = None,
247
+ ) -> None:
248
+ """Initialize error.
249
+
250
+ Args:
251
+ message: Error message
252
+ context: Additional error context
253
+ """
254
+ super().__init__(
255
+ message,
256
+ context=context,
257
+ exit_code=ExitCode.VALIDATION_ERROR,
258
+ )
322
259
 
323
260
 
324
261
  class TemplateValidationError(TaskTemplateError):
@@ -340,18 +277,49 @@ class SchemaError(CLIError):
340
277
 
341
278
 
342
279
  class SchemaFileError(CLIError):
343
- """Raised when a schema file is invalid or inaccessible."""
280
+ """Error raised when schema file cannot be read."""
344
281
 
345
282
  def __init__(
346
283
  self,
347
284
  message: str,
348
285
  schema_path: Optional[str] = None,
349
286
  context: Optional[Dict[str, Any]] = None,
350
- ):
287
+ ) -> None:
288
+ """Initialize schema file error.
289
+
290
+ Args:
291
+ message: Error message
292
+ schema_path: Path to schema file
293
+ context: Additional context for the error
294
+ """
351
295
  context = context or {}
352
- if schema_path:
296
+ if schema_path and "source" not in context:
353
297
  context["schema_path"] = schema_path
354
- super().__init__(message, context)
298
+ context["source"] = schema_path # Use new standard field
299
+ context.setdefault(
300
+ "details",
301
+ "The schema file could not be read or contains errors",
302
+ )
303
+ context.setdefault(
304
+ "troubleshooting",
305
+ [
306
+ "Verify the schema file exists",
307
+ "Check if the schema file contains valid JSON",
308
+ "Ensure the schema follows the correct format",
309
+ "Check file permissions",
310
+ ],
311
+ )
312
+
313
+ super().__init__(
314
+ message,
315
+ context=context,
316
+ exit_code=ExitCode.SCHEMA_ERROR,
317
+ )
318
+
319
+ @property
320
+ def schema_path(self) -> Optional[str]:
321
+ """Get the schema path."""
322
+ return self.context.get("schema_path")
355
323
 
356
324
 
357
325
  class SchemaValidationError(CLIError):
@@ -366,7 +334,23 @@ class SchemaValidationError(CLIError):
366
334
  context = context or {}
367
335
  if schema_path:
368
336
  context["schema_path"] = schema_path
369
- super().__init__(message, context)
337
+ context["source"] = schema_path
338
+ context.setdefault("details", "The schema validation failed")
339
+ context.setdefault(
340
+ "troubleshooting",
341
+ [
342
+ "Check if the schema follows JSON Schema specification",
343
+ "Verify all required fields are present",
344
+ "Ensure field types are correctly specified",
345
+ "Check for any syntax errors in the schema",
346
+ ],
347
+ )
348
+
349
+ super().__init__(
350
+ message,
351
+ context=context,
352
+ exit_code=ExitCode.SCHEMA_ERROR,
353
+ )
370
354
 
371
355
 
372
356
  class ModelCreationError(CLIError):
@@ -415,12 +399,6 @@ class ModelNotSupportedError(CLIError):
415
399
  pass
416
400
 
417
401
 
418
- class ModelVersionError(CLIError):
419
- """Exception raised when a model version is not supported."""
420
-
421
- pass
422
-
423
-
424
402
  class StreamInterruptedError(CLIError):
425
403
  """Exception raised when a stream is interrupted."""
426
404
 
@@ -458,25 +436,34 @@ class InvalidResponseFormatError(CLIError):
458
436
 
459
437
 
460
438
  class OpenAIClientError(CLIError):
461
- """Exception raised when there's an error with the OpenAI client."""
439
+ """Exception raised when there's an error with the OpenAI client.
462
440
 
463
- pass
441
+ This is a wrapper around openai_structured's OpenAIClientError to maintain
442
+ compatibility with our CLI error handling.
443
+ """
444
+
445
+ def __init__(
446
+ self,
447
+ message: str,
448
+ exit_code: ExitCode = ExitCode.API_ERROR,
449
+ context: Optional[Dict[str, Any]] = None,
450
+ ):
451
+ super().__init__(message, exit_code=exit_code, context=context)
464
452
 
465
453
 
466
454
  # Export public API
467
455
  __all__ = [
468
- "CLIError",
469
456
  "VariableError",
470
457
  "PathError",
471
458
  "PathSecurityError",
472
- "FileNotFoundError",
459
+ "OstructFileNotFoundError",
460
+ "FileReadError",
473
461
  "DirectoryNotFoundError",
474
462
  "SchemaValidationError",
475
463
  "SchemaFileError",
476
464
  "InvalidJSONError",
477
465
  "ModelCreationError",
478
466
  "ModelNotSupportedError",
479
- "ModelVersionError",
480
467
  "StreamInterruptedError",
481
468
  "StreamBufferError",
482
469
  "StreamParseError",
@@ -0,0 +1,18 @@
1
+ """Exit codes for CLI operations."""
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class ExitCode(IntEnum):
7
+ """Exit codes for CLI operations."""
8
+
9
+ SUCCESS = 0
10
+ INTERNAL_ERROR = 1
11
+ USAGE_ERROR = 2
12
+ DATA_ERROR = 3
13
+ VALIDATION_ERROR = 4
14
+ API_ERROR = 5
15
+ SCHEMA_ERROR = 6
16
+ UNKNOWN_ERROR = 7
17
+ SECURITY_ERROR = 8
18
+ FILE_ERROR = 9