ostruct-cli 0.3.0__py3-none-any.whl → 0.4.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,11 +1,14 @@
1
1
  """Custom error classes for CLI error handling."""
2
2
 
3
- import os
4
- from pathlib import Path
3
+ import logging
5
4
  from typing import Any, Dict, List, Optional, TextIO, cast
6
5
 
7
6
  import click
8
7
 
8
+ from .security.errors import PathSecurityError as SecurityPathSecurityError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  class CLIError(click.ClickException):
11
14
  """Base class for all CLI errors."""
@@ -96,6 +99,19 @@ class FileNotFoundError(PathError):
96
99
  super().__init__(path, path, context)
97
100
 
98
101
 
102
+ class FileReadError(PathError):
103
+ """Raised when a file cannot be read or decoded.
104
+
105
+ This is a wrapper exception that preserves the original cause (FileNotFoundError,
106
+ UnicodeDecodeError, etc) while providing a consistent interface for error handling.
107
+ """
108
+
109
+ def __init__(
110
+ self, message: str, path: str, context: Optional[Dict[str, Any]] = None
111
+ ):
112
+ super().__init__(message, path, context)
113
+
114
+
99
115
  class DirectoryNotFoundError(PathError):
100
116
  """Raised when a specified directory does not exist."""
101
117
 
@@ -104,12 +120,11 @@ class DirectoryNotFoundError(PathError):
104
120
  super().__init__(path, path, context)
105
121
 
106
122
 
107
- class PathSecurityError(PathError):
108
- """Exception raised when file access is denied due to security constraints.
123
+ class PathSecurityError(CLIError, SecurityPathSecurityError):
124
+ """CLI wrapper for security package's PathSecurityError.
109
125
 
110
- Attributes:
111
- message: The error message with full context
112
- wrapped: Whether this error has been wrapped by another error
126
+ This class bridges the security package's error handling with the CLI's
127
+ error handling system, providing both sets of functionality.
113
128
  """
114
129
 
115
130
  def __init__(
@@ -119,188 +134,65 @@ class PathSecurityError(PathError):
119
134
  context: Optional[Dict[str, Any]] = None,
120
135
  error_logged: bool = False,
121
136
  ):
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
137
+ """Initialize both parent classes properly.
138
+
139
+ Args:
140
+ message: The error message
141
+ path: The path that caused the error
142
+ context: Additional context about the error
143
+ error_logged: Whether this error has been logged
144
+ """
145
+ # Initialize security error first
146
+ SecurityPathSecurityError.__init__(
147
+ self,
148
+ message,
149
+ path=path or "",
150
+ context=context,
151
+ error_logged=error_logged,
152
+ )
153
+ # Initialize CLI error with the same context
154
+ CLIError.__init__(self, message, context=self.context)
155
+ # Ensure error_logged state is consistent
156
+ self._has_been_logged = error_logged
157
+ logger.debug(
158
+ "Created CLI PathSecurityError with message=%r, path=%r, context=%r, error_logged=%r",
159
+ message,
160
+ path,
161
+ self.context,
162
+ error_logged,
126
163
  )
127
- context["wrapped"] = False # Initialize wrapped state
128
- super().__init__(message, path, context)
129
164
 
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
165
+ def show(self, file: Optional[TextIO] = None) -> None:
166
+ """Show the error with CLI formatting."""
167
+ logger.debug("Showing error with context: %r", self.context)
168
+ super().show(file)
138
169
 
139
170
  @property
140
171
  def has_been_logged(self) -> bool:
141
172
  """Whether this error has been logged."""
142
- return bool(self.context.get("has_been_logged", False))
173
+ return self._has_been_logged or super().has_been_logged
143
174
 
144
175
  @has_been_logged.setter
145
176
  def has_been_logged(self, value: bool) -> None:
146
177
  """Set whether this error has been logged."""
147
- self.context["has_been_logged"] = value
178
+ self._has_been_logged = value
179
+ super().has_been_logged = value # type: ignore[misc]
148
180
 
149
181
  @property
150
182
  def error_logged(self) -> bool:
151
- """Alias for has_been_logged for backward compatibility."""
183
+ """Whether this error has been logged (alias for has_been_logged)."""
152
184
  return self.has_been_logged
153
185
 
154
186
  @error_logged.setter
155
187
  def error_logged(self, value: bool) -> None:
156
- """Alias for has_been_logged for backward compatibility."""
188
+ """Set whether this error has been logged (alias for has_been_logged)."""
157
189
  self.has_been_logged = value
158
190
 
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
- )
226
-
227
- @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)
240
-
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
- )
256
-
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)
264
-
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)
287
-
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,
301
- )
302
- error.wrapped = True # Ensure wrapped is set through the property
303
- return error
191
+ def __str__(self) -> str:
192
+ """Format the error message with context."""
193
+ msg = SecurityPathSecurityError.__str__(self)
194
+ logger.debug("Formatted error message: %r", msg)
195
+ return msg
304
196
 
305
197
 
306
198
  class TaskTemplateError(CLIError):
@@ -470,6 +362,7 @@ __all__ = [
470
362
  "PathError",
471
363
  "PathSecurityError",
472
364
  "FileNotFoundError",
365
+ "FileReadError",
473
366
  "DirectoryNotFoundError",
474
367
  "SchemaValidationError",
475
368
  "SchemaFileError",
ostruct/cli/file_info.py CHANGED
@@ -5,7 +5,7 @@ import logging
5
5
  import os
6
6
  from typing import Any, Optional
7
7
 
8
- from .errors import FileNotFoundError, PathSecurityError
8
+ from .errors import FileNotFoundError, FileReadError, PathSecurityError
9
9
  from .security import SecurityManager
10
10
 
11
11
  logger = logging.getLogger(__name__)
@@ -45,6 +45,7 @@ class FileInfo:
45
45
  Raises:
46
46
  FileNotFoundError: If the file does not exist
47
47
  PathSecurityError: If the path is not allowed
48
+ PermissionError: If access is denied
48
49
  """
49
50
  logger.debug("Creating FileInfo for path: %s", path)
50
51
 
@@ -53,7 +54,7 @@ class FileInfo:
53
54
  raise ValueError("Path cannot be empty")
54
55
 
55
56
  # Initialize private attributes
56
- self.__path = os.path.expanduser(os.path.expandvars(path))
57
+ self.__path = str(path)
57
58
  self.__security_manager = security_manager
58
59
  self.__content = content
59
60
  self.__encoding = encoding
@@ -61,44 +62,67 @@ class FileInfo:
61
62
  self.__size: Optional[int] = None
62
63
  self.__mtime: Optional[float] = None
63
64
 
64
- # First validate security and resolve path
65
65
  try:
66
66
  # This will raise PathSecurityError if path is not allowed
67
+ # And FileNotFoundError if the file doesn't exist
67
68
  resolved_path = self.__security_manager.resolve_path(self.__path)
68
69
  logger.debug(
69
70
  "Security-resolved path for %s: %s", path, resolved_path
70
71
  )
71
72
 
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
-
73
+ # Check if it's a regular file (not a directory, device, etc.)
79
74
  if not resolved_path.is_file():
75
+ logger.debug("Not a regular file: %s", resolved_path)
80
76
  raise FileNotFoundError(
81
- f"Not a file: {os.path.basename(path)}"
77
+ f"Not a regular file: {os.path.basename(str(path))}"
82
78
  )
83
79
 
84
- except PathSecurityError:
85
- # Re-raise security errors as is
86
- raise
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)}"
80
+ except PathSecurityError as e:
81
+ # Let security errors propagate directly with context
82
+ logger.error(
83
+ "Security error accessing file %s: %s",
84
+ path,
85
+ str(e),
86
+ extra={
87
+ "path": path,
88
+ "resolved_path": (
89
+ str(resolved_path)
90
+ if "resolved_path" in locals()
91
+ else None
92
+ ),
93
+ "base_dir": str(self.__security_manager.base_dir),
94
+ "allowed_dirs": [
95
+ str(d) for d in self.__security_manager.allowed_dirs
96
+ ],
97
+ },
91
98
  )
92
- except Exception: # Catch all other exceptions
93
- # Convert other errors to FileNotFoundError with simplified message
99
+ raise
100
+
101
+ except FileNotFoundError as e:
102
+ # Re-raise with standardized message format
103
+ logger.debug("File not found error: %s", e)
94
104
  raise FileNotFoundError(
95
- f"File not found: {os.path.basename(path)}"
96
- )
105
+ f"File not found: {os.path.basename(str(path))}"
106
+ ) from e
97
107
 
98
- # If content/encoding weren't provided, read them now
99
- if self.__content is None or self.__encoding is None:
100
- logger.debug("Reading content for %s", path)
101
- self._read_file()
108
+ except PermissionError as e:
109
+ # Handle permission errors with context
110
+ logger.error(
111
+ "Permission denied accessing file %s: %s",
112
+ path,
113
+ str(e),
114
+ extra={
115
+ "path": path,
116
+ "resolved_path": (
117
+ str(resolved_path)
118
+ if "resolved_path" in locals()
119
+ else None
120
+ ),
121
+ },
122
+ )
123
+ raise PermissionError(
124
+ f"Permission denied: {os.path.basename(str(path))}"
125
+ ) from e
102
126
 
103
127
  @property
104
128
  def path(self) -> str:
@@ -154,7 +178,8 @@ class FileInfo:
154
178
  """Get file size in bytes."""
155
179
  if self.__size is None:
156
180
  try:
157
- self.__size = os.path.getsize(self.abs_path)
181
+ size = os.path.getsize(self.abs_path)
182
+ self.__size = size
158
183
  except OSError:
159
184
  logger.warning("Could not get size for %s", self.__path)
160
185
  return None
@@ -170,7 +195,8 @@ class FileInfo:
170
195
  """Get file modification time as Unix timestamp."""
171
196
  if self.__mtime is None:
172
197
  try:
173
- self.__mtime = os.path.getmtime(self.abs_path)
198
+ mtime = os.path.getmtime(self.abs_path)
199
+ self.__mtime = mtime
174
200
  except OSError:
175
201
  logger.warning("Could not get mtime for %s", self.__path)
176
202
  return None
@@ -183,10 +209,25 @@ class FileInfo:
183
209
 
184
210
  @property
185
211
  def content(self) -> str:
186
- """Get the content of the file."""
212
+ """Get the content of the file.
213
+
214
+ Returns:
215
+ str: The file content
216
+
217
+ Raises:
218
+ FileReadError: If the file cannot be read, wrapping the underlying cause
219
+ (FileNotFoundError, UnicodeDecodeError, etc)
220
+ """
221
+ if self.__content is None:
222
+ try:
223
+ self._read_file()
224
+ except Exception as e:
225
+ raise FileReadError(
226
+ f"Failed to load content: {self.__path}", self.__path
227
+ ) from e
187
228
  assert (
188
229
  self.__content is not None
189
- ), "Content should be initialized in constructor"
230
+ ) # Help mypy understand content is set
190
231
  return self.__content
191
232
 
192
233
  @content.setter
@@ -196,10 +237,20 @@ class FileInfo:
196
237
 
197
238
  @property
198
239
  def encoding(self) -> str:
199
- """Get the encoding of the file."""
240
+ """Get the encoding of the file.
241
+
242
+ Returns:
243
+ str: The file encoding (utf-8 or system)
244
+
245
+ Raises:
246
+ FileReadError: If the file cannot be read or decoded
247
+ """
248
+ if self.__encoding is None:
249
+ # This will trigger content loading and may raise FileReadError
250
+ self.content
200
251
  assert (
201
252
  self.__encoding is not None
202
- ), "Encoding should be initialized in constructor"
253
+ ) # Help mypy understand encoding is set
203
254
  return self.__encoding
204
255
 
205
256
  @encoding.setter
@@ -248,34 +299,24 @@ class FileInfo:
248
299
  return False
249
300
 
250
301
  def _read_file(self) -> None:
251
- """Read file content and encoding from disk."""
302
+ """Read and decode file content.
303
+
304
+ Implementation detail: Attempts UTF-8 first, falls back to system encoding.
305
+ All exceptions will be caught and wrapped by the content property.
306
+ """
252
307
  try:
253
308
  with open(self.abs_path, "rb") as f:
254
309
  raw_content = f.read()
255
- except FileNotFoundError as e:
256
- raise FileNotFoundError(f"File not found: {self.__path}") from e
257
- except OSError as e:
258
- raise FileNotFoundError(
259
- f"Could not read file {self.__path}: {e}"
260
- ) from e
261
-
262
- # Try UTF-8 first
263
- try:
264
- self.__content = raw_content.decode("utf-8")
265
- self.__encoding = "utf-8"
266
- return
267
- except UnicodeDecodeError:
268
- pass
269
-
270
- # Fall back to system default encoding
271
- try:
272
- self.__content = raw_content.decode()
273
- self.__encoding = "system"
274
- return
275
- except UnicodeDecodeError as e:
276
- raise ValueError(
277
- f"Could not decode file {self.__path}: {e}"
278
- ) from e
310
+ try:
311
+ self.__content = raw_content.decode("utf-8")
312
+ self.__encoding = "utf-8"
313
+ except UnicodeDecodeError:
314
+ # Fall back to system encoding
315
+ self.__content = raw_content.decode()
316
+ self.__encoding = "system"
317
+ except Exception:
318
+ # Let content property handle all errors
319
+ raise
279
320
 
280
321
  def update_cache(
281
322
  self,