ostruct-cli 0.1.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 ADDED
@@ -0,0 +1,329 @@
1
+ """Custom error classes for CLI error handling."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+
7
+ class CLIError(Exception):
8
+ """Base class for all CLI errors."""
9
+
10
+ pass
11
+
12
+
13
+ class VariableError(CLIError):
14
+ """Base class for variable-related errors."""
15
+
16
+ pass
17
+
18
+
19
+ class VariableNameError(VariableError):
20
+ """Raised when a variable name is invalid or empty."""
21
+
22
+ pass
23
+
24
+
25
+ class VariableValueError(VariableError):
26
+ """Raised when a variable value is invalid or missing."""
27
+
28
+ pass
29
+
30
+
31
+ class InvalidJSONError(VariableError):
32
+ """Raised when JSON parsing fails for a variable value."""
33
+
34
+ pass
35
+
36
+
37
+ class PathError(CLIError):
38
+ """Base class for path-related errors."""
39
+
40
+ pass
41
+
42
+
43
+ class FileNotFoundError(PathError):
44
+ """Raised when a specified file does not exist."""
45
+
46
+ pass
47
+
48
+
49
+ class DirectoryNotFoundError(PathError):
50
+ """Raised when a specified directory does not exist."""
51
+
52
+ pass
53
+
54
+
55
+ class PathSecurityError(Exception):
56
+ """Exception raised when file access is denied due to security constraints.
57
+
58
+ Attributes:
59
+ message: The error message with full context
60
+ error_logged: Whether this error has already been logged
61
+ wrapped: Whether this error has been wrapped by another error
62
+ """
63
+
64
+ 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
78
+
79
+ @property
80
+ 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
83
+
84
+ def __str__(self) -> str:
85
+ """Get string representation of the error."""
86
+ return self.message
87
+
88
+ @classmethod
89
+ def access_denied(
90
+ cls,
91
+ path: Path,
92
+ reason: Optional[str] = None,
93
+ error_logged: bool = False,
94
+ ) -> "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
+ """
105
+ msg = f"Access denied: {path}"
106
+ if reason:
107
+ msg += f" - {reason}"
108
+ return cls(msg, error_logged=error_logged)
109
+
110
+ @classmethod
111
+ def outside_allowed(
112
+ cls,
113
+ path: Path,
114
+ base_dir: Optional[Path] = None,
115
+ error_logged: bool = False,
116
+ ) -> "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
+ ]
130
+ if base_dir:
131
+ parts.append(f"Base directory: {base_dir}")
132
+ parts.append(
133
+ "Use --allowed-dir to specify additional allowed directories"
134
+ )
135
+ return cls("\n".join(parts), error_logged=error_logged)
136
+
137
+ @classmethod
138
+ def traversal_attempt(
139
+ cls, path: Path, error_logged: bool = False
140
+ ) -> "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)
188
+
189
+ def format_with_context(
190
+ self,
191
+ original_path: Optional[str] = None,
192
+ expanded_path: Optional[str] = None,
193
+ base_dir: Optional[str] = None,
194
+ allowed_dirs: Optional[List[str]] = None,
195
+ ) -> 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
+ """
207
+ parts = [self.message]
208
+ if original_path and expanded_path and original_path != expanded_path:
209
+ parts.append(f"Original path: {original_path}")
210
+ parts.append(f"Expanded path: {expanded_path}")
211
+ if base_dir:
212
+ parts.append(f"Base directory: {base_dir}")
213
+ 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
+ parts.append(
222
+ "Use --allowed-dir to specify additional allowed directories"
223
+ )
224
+ return "\n".join(parts)
225
+
226
+ @classmethod
227
+ def wrap_error(
228
+ cls, context: str, original: "PathSecurityError"
229
+ ) -> "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)
242
+
243
+
244
+ class TaskTemplateError(CLIError):
245
+ """Base class for task template-related errors."""
246
+
247
+ pass
248
+
249
+
250
+ class TaskTemplateSyntaxError(TaskTemplateError):
251
+ """Raised when a task template has invalid syntax."""
252
+
253
+ pass
254
+
255
+
256
+ class TaskTemplateVariableError(TaskTemplateError):
257
+ """Raised when a task template uses undefined variables."""
258
+
259
+ pass
260
+
261
+
262
+ class TemplateValidationError(TaskTemplateError):
263
+ """Raised when template validation fails."""
264
+
265
+ pass
266
+
267
+
268
+ class SystemPromptError(TaskTemplateError):
269
+ """Raised when there are issues with system prompt loading or processing."""
270
+
271
+ pass
272
+
273
+
274
+ class SchemaError(CLIError):
275
+ """Base class for schema-related errors."""
276
+
277
+ pass
278
+
279
+
280
+ class SchemaFileError(SchemaError):
281
+ """Raised when a schema file is invalid or inaccessible."""
282
+
283
+ pass
284
+
285
+
286
+ class SchemaValidationError(SchemaError):
287
+ """Raised when a schema fails validation."""
288
+
289
+ pass
290
+
291
+
292
+ class ModelCreationError(SchemaError):
293
+ """Base class for model creation errors."""
294
+
295
+ pass
296
+
297
+
298
+ class FieldDefinitionError(ModelCreationError):
299
+ """Raised when field definition fails."""
300
+
301
+ def __init__(self, field_name: str, field_type: str, error: str):
302
+ self.field_name = field_name
303
+ self.field_type = field_type
304
+ super().__init__(
305
+ f"Failed to define field '{field_name}' of type '{field_type}': {error}"
306
+ )
307
+
308
+
309
+ class NestedModelError(ModelCreationError):
310
+ """Raised when nested model creation fails."""
311
+
312
+ def __init__(self, model_name: str, parent_field: str, error: str):
313
+ self.model_name = model_name
314
+ self.parent_field = parent_field
315
+ super().__init__(
316
+ f"Failed to create nested model '{model_name}' for field '{parent_field}': {error}"
317
+ )
318
+
319
+
320
+ class ModelValidationError(ModelCreationError):
321
+ """Raised when model validation fails."""
322
+
323
+ def __init__(self, model_name: str, validation_errors: List[str]):
324
+ self.model_name = model_name
325
+ self.validation_errors = validation_errors
326
+ super().__init__(
327
+ f"Model '{model_name}' validation failed:\n"
328
+ + "\n".join(validation_errors)
329
+ )
@@ -0,0 +1,316 @@
1
+ """FileInfo class for representing file metadata and content."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ from typing import Any, Optional
7
+
8
+ from .errors import FileNotFoundError, PathSecurityError
9
+ from .security import SecurityManager
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class FileInfo:
15
+ """Represents a file with metadata and content.
16
+
17
+ This class provides access to file metadata (path, size, etc.) and content,
18
+ with caching support for efficient access.
19
+
20
+ Args:
21
+ path: Path to the file
22
+ security_manager: Security manager to use for path validation
23
+ content: Optional cached content
24
+ encoding: Optional cached encoding
25
+ hash_value: Optional cached hash value
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ path: str,
31
+ security_manager: SecurityManager,
32
+ content: Optional[str] = None,
33
+ encoding: Optional[str] = None,
34
+ hash_value: Optional[str] = None,
35
+ ) -> None:
36
+ """Initialize FileInfo instance.
37
+
38
+ Args:
39
+ path: Path to the file
40
+ security_manager: Security manager to use for path validation
41
+ content: Optional cached content
42
+ encoding: Optional cached encoding
43
+ hash_value: Optional cached hash value
44
+
45
+ Raises:
46
+ FileNotFoundError: If the file does not exist
47
+ PathSecurityError: If the path is not allowed
48
+ """
49
+ # Validate path
50
+ if not path:
51
+ raise ValueError("Path cannot be empty")
52
+
53
+ # Initialize private attributes
54
+ self.__path = os.path.expanduser(os.path.expandvars(path))
55
+ self.__security_manager = security_manager
56
+ self.__content = content
57
+ self.__encoding = encoding
58
+ self.__hash = hash_value
59
+ self.__size: Optional[int] = None
60
+ self.__mtime: Optional[float] = None
61
+
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
70
+ try:
71
+ # This will raise PathSecurityError if path is not allowed
72
+ self.abs_path
73
+ except PathSecurityError:
74
+ raise
75
+ except Exception as e:
76
+ raise FileNotFoundError(f"Invalid file path: {e}")
77
+
78
+ # If content/encoding weren't provided, read them now
79
+ if self.__content is None or self.__encoding is None:
80
+ self._read_file()
81
+
82
+ @property
83
+ def path(self) -> str:
84
+ """Get the relative path of the file."""
85
+ # If original path was relative, keep it relative
86
+ if not os.path.isabs(self.__path):
87
+ try:
88
+ base_dir = self.__security_manager.base_dir
89
+ abs_path = self.abs_path
90
+ return os.path.relpath(abs_path, base_dir)
91
+ except ValueError:
92
+ pass
93
+ return self.__path
94
+
95
+ @path.setter
96
+ def path(self, value: str) -> None:
97
+ """Prevent setting path directly."""
98
+ raise AttributeError("Cannot modify path directly")
99
+
100
+ @property
101
+ def abs_path(self) -> str:
102
+ """Get the absolute path of the file.
103
+
104
+ Returns:
105
+ str: The absolute path of the file.
106
+
107
+ Raises:
108
+ PathSecurityError: If the path is not allowed.
109
+ """
110
+ # Get the resolved absolute path through security manager
111
+ resolved = self.__security_manager.resolve_path(self.__path)
112
+
113
+ # Always return absolute path
114
+ return str(resolved)
115
+
116
+ @property
117
+ def extension(self) -> str:
118
+ """Get file extension without dot."""
119
+ return os.path.splitext(self.__path)[1].lstrip(".")
120
+
121
+ @property
122
+ def name(self) -> str:
123
+ """Get the filename without directory path."""
124
+ return os.path.basename(self.__path)
125
+
126
+ @name.setter
127
+ def name(self, value: str) -> None:
128
+ """Prevent setting name directly."""
129
+ raise AttributeError("Cannot modify name directly")
130
+
131
+ @property
132
+ def size(self) -> Optional[int]:
133
+ """Get file size in bytes."""
134
+ if self.__size is None:
135
+ try:
136
+ self.__size = os.path.getsize(self.abs_path)
137
+ except OSError:
138
+ logger.warning("Could not get size for %s", self.__path)
139
+ return None
140
+ return self.__size
141
+
142
+ @size.setter
143
+ def size(self, value: int) -> None:
144
+ """Prevent setting size directly."""
145
+ raise AttributeError("Cannot modify size directly")
146
+
147
+ @property
148
+ def mtime(self) -> Optional[float]:
149
+ """Get file modification time as Unix timestamp."""
150
+ if self.__mtime is None:
151
+ try:
152
+ self.__mtime = os.path.getmtime(self.abs_path)
153
+ except OSError:
154
+ logger.warning("Could not get mtime for %s", self.__path)
155
+ return None
156
+ return self.__mtime
157
+
158
+ @mtime.setter
159
+ def mtime(self, value: float) -> None:
160
+ """Prevent setting mtime directly."""
161
+ raise AttributeError("Cannot modify mtime directly")
162
+
163
+ @property
164
+ def content(self) -> str:
165
+ """Get the content of the file."""
166
+ assert (
167
+ self.__content is not None
168
+ ), "Content should be initialized in constructor"
169
+ return self.__content
170
+
171
+ @content.setter
172
+ def content(self, value: str) -> None:
173
+ """Prevent setting content directly."""
174
+ raise AttributeError("Cannot modify content directly")
175
+
176
+ @property
177
+ def encoding(self) -> str:
178
+ """Get the encoding of the file."""
179
+ assert (
180
+ self.__encoding is not None
181
+ ), "Encoding should be initialized in constructor"
182
+ return self.__encoding
183
+
184
+ @encoding.setter
185
+ def encoding(self, value: str) -> None:
186
+ """Prevent setting encoding directly."""
187
+ raise AttributeError("Cannot modify encoding directly")
188
+
189
+ @property
190
+ def hash(self) -> Optional[str]:
191
+ """Get SHA-256 hash of file content."""
192
+ if self.__hash is None and self.__content is not None:
193
+ self.__hash = hashlib.sha256(
194
+ self.__content.encode("utf-8")
195
+ ).hexdigest()
196
+ return self.__hash
197
+
198
+ @hash.setter
199
+ def hash(self, value: str) -> None:
200
+ """Prevent setting hash directly."""
201
+ raise AttributeError("Cannot modify hash directly")
202
+
203
+ def _read_file(self) -> None:
204
+ """Read file content and encoding from disk."""
205
+ try:
206
+ with open(self.abs_path, "rb") as f:
207
+ raw_content = f.read()
208
+ except FileNotFoundError as e:
209
+ raise FileNotFoundError(f"File not found: {self.__path}") from e
210
+ except OSError as e:
211
+ raise FileNotFoundError(
212
+ f"Could not read file {self.__path}: {e}"
213
+ ) from e
214
+
215
+ # Try UTF-8 first
216
+ try:
217
+ self.__content = raw_content.decode("utf-8")
218
+ self.__encoding = "utf-8"
219
+ return
220
+ except UnicodeDecodeError:
221
+ pass
222
+
223
+ # Fall back to system default encoding
224
+ try:
225
+ self.__content = raw_content.decode()
226
+ self.__encoding = "system"
227
+ return
228
+ except UnicodeDecodeError as e:
229
+ raise ValueError(
230
+ f"Could not decode file {self.__path}: {e}"
231
+ ) from e
232
+
233
+ def update_cache(
234
+ self,
235
+ content: Optional[str] = None,
236
+ encoding: Optional[str] = None,
237
+ hash_value: Optional[str] = None,
238
+ ) -> None:
239
+ """Update cached values.
240
+
241
+ Args:
242
+ content: New content to cache
243
+ encoding: New encoding to cache
244
+ hash_value: New hash value to cache
245
+ """
246
+ if content is not None:
247
+ self.__content = content
248
+ if encoding is not None:
249
+ self.__encoding = encoding
250
+ if hash_value is not None:
251
+ self.__hash = hash_value
252
+
253
+ @classmethod
254
+ def from_path(
255
+ cls, path: str, security_manager: SecurityManager
256
+ ) -> "FileInfo":
257
+ """Create FileInfo instance from path.
258
+
259
+ Args:
260
+ path: Path to file
261
+ security_manager: Security manager for path validation
262
+
263
+ Returns:
264
+ FileInfo instance
265
+
266
+ Raises:
267
+ FileNotFoundError: If file does not exist
268
+ PathSecurityError: If path is not allowed
269
+ """
270
+ return cls(path, security_manager)
271
+
272
+ def __str__(self) -> str:
273
+ """String representation showing path."""
274
+ return f"FileInfo({self.__path})"
275
+
276
+ def __repr__(self) -> str:
277
+ """Detailed representation."""
278
+ return (
279
+ f"FileInfo(path={self.__path!r}, "
280
+ f"size={self.size!r}, "
281
+ f"encoding={self.encoding!r}, "
282
+ f"hash={self.hash!r})"
283
+ )
284
+
285
+ def __setattr__(self, name: str, value: Any) -> None:
286
+ """Control attribute modification.
287
+
288
+ Internal methods can modify private attributes, but external access is prevented.
289
+ """
290
+ # Allow setting private attributes from internal methods
291
+ if name.startswith("_FileInfo__") and self._is_internal_call():
292
+ object.__setattr__(self, name, value)
293
+ return
294
+
295
+ # Prevent setting other attributes
296
+ raise AttributeError(f"Cannot modify {name} directly")
297
+
298
+ def _is_internal_call(self) -> bool:
299
+ """Check if the call is from an internal method."""
300
+ import inspect
301
+
302
+ frame = inspect.currentframe()
303
+ try:
304
+ # Get the caller's frame (2 frames up: _is_internal_call -> __setattr__ -> caller)
305
+ caller = frame.f_back.f_back if frame and frame.f_back else None
306
+ if not caller:
307
+ return False
308
+
309
+ # Check if the caller is a method of this class
310
+ return (
311
+ caller.f_code.co_name in type(self).__dict__
312
+ and "self" in caller.f_locals
313
+ and caller.f_locals["self"] is self
314
+ )
315
+ finally:
316
+ del frame # Avoid reference cycles