ostruct-cli 0.1.4__py3-none-any.whl → 0.3.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/errors.py CHANGED
@@ -1,13 +1,47 @@
1
1
  """Custom error classes for CLI error handling."""
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
- from typing import List, Optional
5
+ from typing import Any, Dict, List, Optional, TextIO, cast
5
6
 
7
+ import click
6
8
 
7
- class CLIError(Exception):
9
+
10
+ class CLIError(click.ClickException):
8
11
  """Base class for all CLI errors."""
9
12
 
10
- pass
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
45
 
12
46
 
13
47
  class VariableError(CLIError):
@@ -28,62 +62,167 @@ class VariableValueError(VariableError):
28
62
  pass
29
63
 
30
64
 
31
- class InvalidJSONError(VariableError):
65
+ class InvalidJSONError(CLIError):
32
66
  """Raised when JSON parsing fails for a variable value."""
33
67
 
34
- pass
68
+ def __init__(
69
+ self,
70
+ message: str,
71
+ source: Optional[str] = None,
72
+ context: Optional[Dict[str, Any]] = None,
73
+ ):
74
+ context = context or {}
75
+ if source:
76
+ context["source"] = source
77
+ super().__init__(message, context)
35
78
 
36
79
 
37
80
  class PathError(CLIError):
38
81
  """Base class for path-related errors."""
39
82
 
40
- pass
83
+ def __init__(
84
+ self, message: str, path: str, context: Optional[Dict[str, Any]] = None
85
+ ):
86
+ context = context or {}
87
+ context["path"] = path
88
+ super().__init__(message, context)
41
89
 
42
90
 
43
91
  class FileNotFoundError(PathError):
44
92
  """Raised when a specified file does not exist."""
45
93
 
46
- pass
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)
47
97
 
48
98
 
49
99
  class DirectoryNotFoundError(PathError):
50
100
  """Raised when a specified directory does not exist."""
51
101
 
52
- pass
102
+ 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)
53
105
 
54
106
 
55
- class PathSecurityError(Exception):
107
+ class PathSecurityError(PathError):
56
108
  """Exception raised when file access is denied due to security constraints.
57
109
 
58
110
  Attributes:
59
111
  message: The error message with full context
60
- error_logged: Whether this error has already been logged
61
112
  wrapped: Whether this error has been wrapped by another error
62
113
  """
63
114
 
64
115
  def __init__(
65
- self, message: str, error_logged: bool = False, wrapped: bool = False
66
- ) -> None:
67
- """Initialize PathSecurityError.
68
-
69
- Args:
70
- message: Detailed error message with context
71
- error_logged: Whether this error has already been logged
72
- wrapped: Whether this error has been wrapped by another error
73
- """
74
- super().__init__(message)
75
- self.error_logged = error_logged
76
- self.message = message
77
- self.wrapped = wrapped
116
+ self,
117
+ message: str,
118
+ path: Optional[str] = None,
119
+ context: Optional[Dict[str, Any]] = None,
120
+ error_logged: bool = False,
121
+ ):
122
+ path = path or "unknown" # Provide default path if none given
123
+ 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
78
138
 
79
139
  @property
80
140
  def has_been_logged(self) -> bool:
81
- """Check if this error has been logged, more readable than accessing error_logged directly."""
82
- return self.error_logged
141
+ """Whether this error has been logged."""
142
+ return bool(self.context.get("has_been_logged", False))
83
143
 
84
- def __str__(self) -> str:
85
- """Get string representation of the error."""
86
- return self.message
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
148
+
149
+ @property
150
+ 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
158
+
159
+ @property
160
+ 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"
183
+
184
+ @classmethod
185
+ def from_expanded_paths(
186
+ cls,
187
+ original_path: str,
188
+ expanded_path: str,
189
+ base_dir: Optional[str] = None,
190
+ allowed_dirs: Optional[List[str]] = None,
191
+ error_logged: bool = False,
192
+ ) -> "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
+
198
+ context = {
199
+ "original_path": original_path,
200
+ "expanded_path": expanded_path,
201
+ "has_been_logged": error_logged,
202
+ }
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
+
220
+ return cls(
221
+ "\n".join(parts),
222
+ path=original_path,
223
+ context=context,
224
+ error_logged=error_logged,
225
+ )
87
226
 
88
227
  @classmethod
89
228
  def access_denied(
@@ -92,20 +231,12 @@ class PathSecurityError(Exception):
92
231
  reason: Optional[str] = None,
93
232
  error_logged: bool = False,
94
233
  ) -> "PathSecurityError":
95
- """Create access denied error.
96
-
97
- Args:
98
- path: Path that was denied
99
- reason: Optional reason for denial
100
- error_logged: Whether this error has already been logged
101
-
102
- Returns:
103
- PathSecurityError with standardized message
104
- """
234
+ """Create access denied error."""
105
235
  msg = f"Access denied: {path}"
106
236
  if reason:
107
237
  msg += f" - {reason}"
108
- return cls(msg, error_logged=error_logged)
238
+ msg += " is outside base directory and not in allowed directories"
239
+ return cls(msg, path=str(path), error_logged=error_logged)
109
240
 
110
241
  @classmethod
111
242
  def outside_allowed(
@@ -114,77 +245,22 @@ class PathSecurityError(Exception):
114
245
  base_dir: Optional[Path] = None,
115
246
  error_logged: bool = False,
116
247
  ) -> "PathSecurityError":
117
- """Create error for path outside allowed directories.
118
-
119
- Args:
120
- path: Path that was outside
121
- base_dir: Optional base directory for context
122
- error_logged: Whether this error has already been logged
123
-
124
- Returns:
125
- PathSecurityError with standardized message
126
- """
127
- parts = [
128
- f"Access denied: {path} is outside base directory and not in allowed directories"
129
- ]
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 = {}
130
251
  if base_dir:
131
- parts.append(f"Base directory: {base_dir}")
132
- parts.append(
133
- "Use --allowed-dir to specify additional allowed directories"
252
+ context["base_directory"] = str(base_dir)
253
+ return cls(
254
+ msg, path=str(path), context=context, error_logged=error_logged
134
255
  )
135
- return cls("\n".join(parts), error_logged=error_logged)
136
256
 
137
257
  @classmethod
138
258
  def traversal_attempt(
139
259
  cls, path: Path, error_logged: bool = False
140
260
  ) -> "PathSecurityError":
141
- """Create error for directory traversal attempt.
142
-
143
- Args:
144
- path: Path that attempted traversal
145
- error_logged: Whether this error has already been logged
146
-
147
- Returns:
148
- PathSecurityError with standardized message
149
- """
150
- return cls(
151
- f"Access denied: {path} - directory traversal not allowed",
152
- error_logged=error_logged,
153
- )
154
-
155
- @classmethod
156
- def from_expanded_paths(
157
- cls,
158
- original_path: str,
159
- expanded_path: str,
160
- base_dir: Optional[str] = None,
161
- allowed_dirs: Optional[List[str]] = None,
162
- error_logged: bool = False,
163
- ) -> "PathSecurityError":
164
- """Create error with expanded path context.
165
-
166
- Args:
167
- original_path: Original path as provided by user
168
- expanded_path: Expanded absolute path
169
- base_dir: Optional base directory
170
- allowed_dirs: Optional list of allowed directories
171
- error_logged: Whether this error has already been logged
172
-
173
- Returns:
174
- PathSecurityError with detailed path context
175
- """
176
- parts = [
177
- f"Access denied: {original_path} is outside base directory and not in allowed directories",
178
- f"File absolute path: {expanded_path}",
179
- ]
180
- if base_dir:
181
- parts.append(f"Base directory: {base_dir}")
182
- if allowed_dirs:
183
- parts.append(f"Allowed directories: {allowed_dirs}")
184
- parts.append(
185
- "Use --allowed-dir to specify additional allowed directories"
186
- )
187
- return cls("\n".join(parts), error_logged=error_logged)
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)
188
264
 
189
265
  def format_with_context(
190
266
  self,
@@ -193,17 +269,7 @@ class PathSecurityError(Exception):
193
269
  base_dir: Optional[str] = None,
194
270
  allowed_dirs: Optional[List[str]] = None,
195
271
  ) -> str:
196
- """Format error message with additional context.
197
-
198
- Args:
199
- original_path: Optional original path as provided by user
200
- expanded_path: Optional expanded absolute path
201
- base_dir: Optional base directory
202
- allowed_dirs: Optional list of allowed directories
203
-
204
- Returns:
205
- Formatted error message with context
206
- """
272
+ """Format error message with additional context."""
207
273
  parts = [self.message]
208
274
  if original_path and expanded_path and original_path != expanded_path:
209
275
  parts.append(f"Original path: {original_path}")
@@ -211,34 +277,30 @@ class PathSecurityError(Exception):
211
277
  if base_dir:
212
278
  parts.append(f"Base directory: {base_dir}")
213
279
  if allowed_dirs:
214
- parts.append(f"Allowed directories: {allowed_dirs}")
215
- if not any(
216
- p.endswith(
217
- "Use --allowed-dir to specify additional allowed directories"
218
- )
219
- for p in parts
220
- ):
221
280
  parts.append(
222
- "Use --allowed-dir to specify additional allowed directories"
281
+ f"Allowed directories: {self._format_allowed_dirs(allowed_dirs)}"
223
282
  )
283
+ parts.append(
284
+ "Use --allowed-dir to specify additional allowed directories"
285
+ )
224
286
  return "\n".join(parts)
225
287
 
226
288
  @classmethod
227
289
  def wrap_error(
228
290
  cls, context: str, original: "PathSecurityError"
229
291
  ) -> "PathSecurityError":
230
- """Wrap an error with additional context while preserving attributes.
231
-
232
- Args:
233
- context: Additional context to add to the error message
234
- original: The original PathSecurityError to wrap
235
-
236
- Returns:
237
- PathSecurityError with additional context and preserved attributes
238
- """
239
- base_message = str(original)
240
- message = f"{context}: {base_message}"
241
- return cls(message, error_logged=original.error_logged, wrapped=True)
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,
301
+ )
302
+ error.wrapped = True # Ensure wrapped is set through the property
303
+ return error
242
304
 
243
305
 
244
306
  class TaskTemplateError(CLIError):
@@ -277,19 +339,37 @@ class SchemaError(CLIError):
277
339
  pass
278
340
 
279
341
 
280
- class SchemaFileError(SchemaError):
342
+ class SchemaFileError(CLIError):
281
343
  """Raised when a schema file is invalid or inaccessible."""
282
344
 
283
- pass
345
+ def __init__(
346
+ self,
347
+ message: str,
348
+ schema_path: Optional[str] = None,
349
+ context: Optional[Dict[str, Any]] = None,
350
+ ):
351
+ context = context or {}
352
+ if schema_path:
353
+ context["schema_path"] = schema_path
354
+ super().__init__(message, context)
284
355
 
285
356
 
286
- class SchemaValidationError(SchemaError):
357
+ class SchemaValidationError(CLIError):
287
358
  """Raised when a schema fails validation."""
288
359
 
289
- pass
360
+ def __init__(
361
+ self,
362
+ message: str,
363
+ schema_path: Optional[str] = None,
364
+ context: Optional[Dict[str, Any]] = None,
365
+ ):
366
+ context = context or {}
367
+ if schema_path:
368
+ context["schema_path"] = schema_path
369
+ super().__init__(message, context)
290
370
 
291
371
 
292
- class ModelCreationError(SchemaError):
372
+ class ModelCreationError(CLIError):
293
373
  """Base class for model creation errors."""
294
374
 
295
375
  pass
@@ -327,3 +407,81 @@ class ModelValidationError(ModelCreationError):
327
407
  f"Model '{model_name}' validation failed:\n"
328
408
  + "\n".join(validation_errors)
329
409
  )
410
+
411
+
412
+ class ModelNotSupportedError(CLIError):
413
+ """Exception raised when a model doesn't support structured output."""
414
+
415
+ pass
416
+
417
+
418
+ class ModelVersionError(CLIError):
419
+ """Exception raised when a model version is not supported."""
420
+
421
+ pass
422
+
423
+
424
+ class StreamInterruptedError(CLIError):
425
+ """Exception raised when a stream is interrupted."""
426
+
427
+ pass
428
+
429
+
430
+ class StreamBufferError(CLIError):
431
+ """Exception raised when there's an error with the stream buffer."""
432
+
433
+ pass
434
+
435
+
436
+ class StreamParseError(CLIError):
437
+ """Exception raised when there's an error parsing the stream."""
438
+
439
+ pass
440
+
441
+
442
+ class APIResponseError(CLIError):
443
+ """Exception raised when there's an error with the API response."""
444
+
445
+ pass
446
+
447
+
448
+ class EmptyResponseError(CLIError):
449
+ """Exception raised when the API returns an empty response."""
450
+
451
+ pass
452
+
453
+
454
+ class InvalidResponseFormatError(CLIError):
455
+ """Exception raised when the API response format is invalid."""
456
+
457
+ pass
458
+
459
+
460
+ class OpenAIClientError(CLIError):
461
+ """Exception raised when there's an error with the OpenAI client."""
462
+
463
+ pass
464
+
465
+
466
+ # Export public API
467
+ __all__ = [
468
+ "CLIError",
469
+ "VariableError",
470
+ "PathError",
471
+ "PathSecurityError",
472
+ "FileNotFoundError",
473
+ "DirectoryNotFoundError",
474
+ "SchemaValidationError",
475
+ "SchemaFileError",
476
+ "InvalidJSONError",
477
+ "ModelCreationError",
478
+ "ModelNotSupportedError",
479
+ "ModelVersionError",
480
+ "StreamInterruptedError",
481
+ "StreamBufferError",
482
+ "StreamParseError",
483
+ "APIResponseError",
484
+ "EmptyResponseError",
485
+ "InvalidResponseFormatError",
486
+ "OpenAIClientError",
487
+ ]
ostruct/cli/file_info.py CHANGED
@@ -46,6 +46,8 @@ class FileInfo:
46
46
  FileNotFoundError: If the file does not exist
47
47
  PathSecurityError: If the path is not allowed
48
48
  """
49
+ logger.debug("Creating FileInfo for path: %s", path)
50
+
49
51
  # Validate path
50
52
  if not path:
51
53
  raise ValueError("Path cannot be empty")
@@ -59,24 +61,43 @@ class FileInfo:
59
61
  self.__size: Optional[int] = None
60
62
  self.__mtime: Optional[float] = None
61
63
 
62
- # First check if file exists
63
- abs_path = os.path.abspath(self.__path)
64
- if not os.path.exists(abs_path):
65
- raise FileNotFoundError(f"File not found: {path}")
66
- if not os.path.isfile(abs_path):
67
- raise FileNotFoundError(f"Path is not a file: {path}")
68
-
69
- # Then validate security
64
+ # First validate security and resolve path
70
65
  try:
71
66
  # This will raise PathSecurityError if path is not allowed
72
- self.abs_path
67
+ resolved_path = self.__security_manager.resolve_path(self.__path)
68
+ logger.debug(
69
+ "Security-resolved path for %s: %s", path, resolved_path
70
+ )
71
+
72
+ # Now check if the file exists and is accessible
73
+ if not resolved_path.exists():
74
+ # Use the original path in the error message
75
+ raise FileNotFoundError(
76
+ f"File not found: {os.path.basename(path)}"
77
+ )
78
+
79
+ if not resolved_path.is_file():
80
+ raise FileNotFoundError(
81
+ f"Not a file: {os.path.basename(path)}"
82
+ )
83
+
73
84
  except PathSecurityError:
85
+ # Re-raise security errors as is
74
86
  raise
75
- except Exception as e:
76
- raise FileNotFoundError(f"Invalid file path: {e}")
87
+ except FileNotFoundError:
88
+ # Re-raise file not found errors with simplified message
89
+ raise FileNotFoundError(
90
+ f"File not found: {os.path.basename(path)}"
91
+ )
92
+ except Exception: # Catch all other exceptions
93
+ # Convert other errors to FileNotFoundError with simplified message
94
+ raise FileNotFoundError(
95
+ f"File not found: {os.path.basename(path)}"
96
+ )
77
97
 
78
98
  # If content/encoding weren't provided, read them now
79
99
  if self.__content is None or self.__encoding is None:
100
+ logger.debug("Reading content for %s", path)
80
101
  self._read_file()
81
102
 
82
103
  @property
@@ -200,6 +221,32 @@ class FileInfo:
200
221
  """Prevent setting hash directly."""
201
222
  raise AttributeError("Cannot modify hash directly")
202
223
 
224
+ @property
225
+ def exists(self) -> bool:
226
+ """Check if the file exists.
227
+
228
+ Returns:
229
+ bool: True if the file exists, False otherwise
230
+ """
231
+ try:
232
+ return os.path.exists(self.abs_path)
233
+ except (OSError, PathSecurityError):
234
+ return False
235
+
236
+ @property
237
+ def is_binary(self) -> bool:
238
+ """Check if the file appears to be binary.
239
+
240
+ Returns:
241
+ bool: True if the file appears to be binary, False otherwise
242
+ """
243
+ try:
244
+ with open(self.abs_path, "rb") as f:
245
+ chunk = f.read(1024)
246
+ return b"\0" in chunk
247
+ except (OSError, PathSecurityError):
248
+ return False
249
+
203
250
  def _read_file(self) -> None:
204
251
  """Read file content and encoding from disk."""
205
252
  try:
@@ -314,3 +361,27 @@ class FileInfo:
314
361
  )
315
362
  finally:
316
363
  del frame # Avoid reference cycles
364
+
365
+ def to_dict(self) -> dict[str, Any]:
366
+ """Convert file info to a dictionary.
367
+
368
+ Returns:
369
+ Dictionary containing file metadata and content
370
+ """
371
+ # Get file stats
372
+ stats = os.stat(self.abs_path)
373
+
374
+ return {
375
+ "path": self.path,
376
+ "abs_path": str(self.abs_path),
377
+ "exists": self.exists,
378
+ "size": self.size,
379
+ "content": self.content,
380
+ "encoding": self.encoding,
381
+ "hash": self.hash,
382
+ "mtime": self.mtime,
383
+ "mtime_ns": (
384
+ int(self.mtime * 1e9) if self.mtime is not None else None
385
+ ),
386
+ "mode": stats.st_mode,
387
+ }