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/file_info.py CHANGED
@@ -3,9 +3,10 @@
3
3
  import hashlib
4
4
  import logging
5
5
  import os
6
+ from pathlib import Path
6
7
  from typing import Any, Optional
7
8
 
8
- from .errors import FileNotFoundError, PathSecurityError
9
+ from .errors import FileReadError, OstructFileNotFoundError, PathSecurityError
9
10
  from .security import SecurityManager
10
11
 
11
12
  logger = logging.getLogger(__name__)
@@ -45,6 +46,7 @@ class FileInfo:
45
46
  Raises:
46
47
  FileNotFoundError: If the file does not exist
47
48
  PathSecurityError: If the path is not allowed
49
+ PermissionError: If access is denied
48
50
  """
49
51
  logger.debug("Creating FileInfo for path: %s", path)
50
52
 
@@ -53,7 +55,7 @@ class FileInfo:
53
55
  raise ValueError("Path cannot be empty")
54
56
 
55
57
  # Initialize private attributes
56
- self.__path = os.path.expanduser(os.path.expandvars(path))
58
+ self.__path = str(path)
57
59
  self.__security_manager = security_manager
58
60
  self.__content = content
59
61
  self.__encoding = encoding
@@ -61,57 +63,95 @@ class FileInfo:
61
63
  self.__size: Optional[int] = None
62
64
  self.__mtime: Optional[float] = None
63
65
 
64
- # First validate security and resolve path
65
66
  try:
66
67
  # This will raise PathSecurityError if path is not allowed
68
+ # And FileNotFoundError if the file doesn't exist
67
69
  resolved_path = self.__security_manager.resolve_path(self.__path)
68
70
  logger.debug(
69
71
  "Security-resolved path for %s: %s", path, resolved_path
70
72
  )
71
73
 
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
-
74
+ # Check if it's a regular file (not a directory, device, etc.)
79
75
  if not resolved_path.is_file():
80
- raise FileNotFoundError(
81
- f"Not a file: {os.path.basename(path)}"
76
+ logger.debug("Not a regular file: %s", resolved_path)
77
+ raise OstructFileNotFoundError(
78
+ f"Not a regular file: {os.path.basename(str(path))}"
82
79
  )
83
80
 
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)}"
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)}"
81
+ except PathSecurityError as e:
82
+ # Let security errors propagate directly with context
83
+ logger.error(
84
+ "Security error accessing file %s: %s",
85
+ path,
86
+ str(e),
87
+ extra={
88
+ "path": path,
89
+ "resolved_path": (
90
+ str(resolved_path)
91
+ if "resolved_path" in locals()
92
+ else None
93
+ ),
94
+ "base_dir": str(self.__security_manager.base_dir),
95
+ "allowed_dirs": [
96
+ str(d) for d in self.__security_manager.allowed_dirs
97
+ ],
98
+ },
96
99
  )
100
+ raise
97
101
 
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()
102
+ except OstructFileNotFoundError as e:
103
+ # Re-raise with standardized message format
104
+ logger.debug("File not found error: %s", e)
105
+ raise OstructFileNotFoundError(
106
+ f"File not found: {os.path.basename(str(path))}"
107
+ ) from e
108
+
109
+ except PermissionError as e:
110
+ # Handle permission errors with context
111
+ logger.error(
112
+ "Permission denied accessing file %s: %s",
113
+ path,
114
+ str(e),
115
+ extra={
116
+ "path": path,
117
+ "resolved_path": (
118
+ str(resolved_path)
119
+ if "resolved_path" in locals()
120
+ else None
121
+ ),
122
+ },
123
+ )
124
+ raise PermissionError(
125
+ f"Permission denied: {os.path.basename(str(path))}"
126
+ ) from e
102
127
 
103
128
  @property
104
129
  def path(self) -> str:
105
- """Get the relative path of the file."""
106
- # If original path was relative, keep it relative
107
- if not os.path.isabs(self.__path):
108
- try:
109
- base_dir = self.__security_manager.base_dir
110
- abs_path = self.abs_path
111
- return os.path.relpath(abs_path, base_dir)
112
- except ValueError:
113
- pass
114
- return self.__path
130
+ """Get the path relative to security manager's base directory.
131
+
132
+ Returns a path relative to the security manager's base directory.
133
+ This ensures consistent path handling across the entire codebase.
134
+
135
+ Example:
136
+ security_manager = SecurityManager(base_dir="/base")
137
+ file_info = FileInfo("/base/file.txt", security_manager)
138
+ print(file_info.path) # Outputs: "file.txt"
139
+
140
+ Returns:
141
+ str: Path relative to security manager's base directory
142
+
143
+ Raises:
144
+ ValueError: If the path is not within the base directory
145
+ """
146
+ try:
147
+ abs_path = Path(self.abs_path)
148
+ base_dir = Path(self.__security_manager.base_dir)
149
+ return str(abs_path.relative_to(base_dir))
150
+ except ValueError as e:
151
+ logger.error("Error making path relative: %s", e)
152
+ raise ValueError(
153
+ f"Path {abs_path} must be within base directory {base_dir}"
154
+ )
115
155
 
116
156
  @path.setter
117
157
  def path(self, value: str) -> None:
@@ -154,7 +194,8 @@ class FileInfo:
154
194
  """Get file size in bytes."""
155
195
  if self.__size is None:
156
196
  try:
157
- self.__size = os.path.getsize(self.abs_path)
197
+ size = os.path.getsize(self.abs_path)
198
+ self.__size = size
158
199
  except OSError:
159
200
  logger.warning("Could not get size for %s", self.__path)
160
201
  return None
@@ -170,7 +211,8 @@ class FileInfo:
170
211
  """Get file modification time as Unix timestamp."""
171
212
  if self.__mtime is None:
172
213
  try:
173
- self.__mtime = os.path.getmtime(self.abs_path)
214
+ mtime = os.path.getmtime(self.abs_path)
215
+ self.__mtime = mtime
174
216
  except OSError:
175
217
  logger.warning("Could not get mtime for %s", self.__path)
176
218
  return None
@@ -183,10 +225,25 @@ class FileInfo:
183
225
 
184
226
  @property
185
227
  def content(self) -> str:
186
- """Get the content of the file."""
228
+ """Get the content of the file.
229
+
230
+ Returns:
231
+ str: The file content
232
+
233
+ Raises:
234
+ FileReadError: If the file cannot be read, wrapping the underlying cause
235
+ (FileNotFoundError, UnicodeDecodeError, etc)
236
+ """
237
+ if self.__content is None:
238
+ try:
239
+ self._read_file()
240
+ except Exception as e:
241
+ raise FileReadError(
242
+ f"Failed to load content: {self.__path}", self.__path
243
+ ) from e
187
244
  assert (
188
245
  self.__content is not None
189
- ), "Content should be initialized in constructor"
246
+ ) # Help mypy understand content is set
190
247
  return self.__content
191
248
 
192
249
  @content.setter
@@ -196,10 +253,20 @@ class FileInfo:
196
253
 
197
254
  @property
198
255
  def encoding(self) -> str:
199
- """Get the encoding of the file."""
256
+ """Get the encoding of the file.
257
+
258
+ Returns:
259
+ str: The file encoding (utf-8 or system)
260
+
261
+ Raises:
262
+ FileReadError: If the file cannot be read or decoded
263
+ """
264
+ if self.__encoding is None:
265
+ # This will trigger content loading and may raise FileReadError
266
+ self.content
200
267
  assert (
201
268
  self.__encoding is not None
202
- ), "Encoding should be initialized in constructor"
269
+ ) # Help mypy understand encoding is set
203
270
  return self.__encoding
204
271
 
205
272
  @encoding.setter
@@ -248,34 +315,24 @@ class FileInfo:
248
315
  return False
249
316
 
250
317
  def _read_file(self) -> None:
251
- """Read file content and encoding from disk."""
318
+ """Read and decode file content.
319
+
320
+ Implementation detail: Attempts UTF-8 first, falls back to system encoding.
321
+ All exceptions will be caught and wrapped by the content property.
322
+ """
252
323
  try:
253
324
  with open(self.abs_path, "rb") as f:
254
325
  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
326
+ try:
327
+ self.__content = raw_content.decode("utf-8")
328
+ self.__encoding = "utf-8"
329
+ except UnicodeDecodeError:
330
+ # Fall back to system encoding
331
+ self.__content = raw_content.decode()
332
+ self.__encoding = "system"
333
+ except Exception:
334
+ # Let content property handle all errors
335
+ raise
279
336
 
280
337
  def update_cache(
281
338
  self,
ostruct/cli/file_list.py CHANGED
@@ -1,7 +1,8 @@
1
1
  """FileInfoList implementation providing smart file content access."""
2
2
 
3
3
  import logging
4
- from typing import Iterator, List, SupportsIndex, Union, overload
4
+ import threading
5
+ from typing import Iterable, Iterator, List, SupportsIndex, Union, overload
5
6
 
6
7
  from .file_info import FileInfo
7
8
 
@@ -18,6 +19,12 @@ class FileInfoList(List[FileInfo]):
18
19
  properties like content return the value directly. For multiple files or directory
19
20
  mappings, properties return a list of values.
20
21
 
22
+ This class is thread-safe. All operations that access or modify the internal list
23
+ are protected by a reentrant lock (RLock). This allows nested method calls while
24
+ holding the lock, preventing deadlocks in cases like:
25
+ content property → __len__ → lock
26
+ content property → __getitem__ → lock
27
+
21
28
  Examples:
22
29
  Single file (--file):
23
30
  files = FileInfoList([file_info], from_dir=False)
@@ -52,6 +59,7 @@ class FileInfoList(List[FileInfo]):
52
59
  len(files),
53
60
  from_dir,
54
61
  )
62
+ self._lock = threading.RLock() # Use RLock for nested method calls
55
63
  super().__init__(files)
56
64
  self._from_dir = from_dir
57
65
 
@@ -61,25 +69,39 @@ class FileInfoList(List[FileInfo]):
61
69
 
62
70
  Returns:
63
71
  Union[str, List[str]]: For a single file from file mapping, returns its content as a string.
64
- For multiple files or directory mapping, returns a list of contents.
65
-
66
- Raises:
67
- ValueError: If the list is empty
72
+ For multiple files, directory mapping, or empty list, returns a list of contents.
68
73
  """
69
- if not self:
70
- logger.debug("FileInfoList.content called but list is empty")
71
- raise ValueError("No files in FileInfoList")
72
- if len(self) == 1 and not self._from_dir:
74
+ # Take snapshot under lock
75
+ with self._lock:
76
+ if not self:
77
+ logger.debug("FileInfoList.content called but list is empty")
78
+ return []
79
+
80
+ # Make a copy of the files we need to access
81
+ if len(self) == 1 and not self._from_dir:
82
+ file_info = self[0]
83
+ is_single = True
84
+ else:
85
+ files = list(self)
86
+ is_single = False
87
+
88
+ # Access file contents outside lock to prevent deadlocks
89
+ try:
90
+ if is_single:
91
+ logger.debug(
92
+ "FileInfoList.content returning single file content (not from dir)"
93
+ )
94
+ return file_info.content
95
+
73
96
  logger.debug(
74
- "FileInfoList.content returning single file content (not from dir)"
97
+ "FileInfoList.content returning list of %d contents (from_dir=%s)",
98
+ len(files),
99
+ self._from_dir,
75
100
  )
76
- return self[0].content
77
- logger.debug(
78
- "FileInfoList.content returning list of %d contents (from_dir=%s)",
79
- len(self),
80
- self._from_dir,
81
- )
82
- return [f.content for f in self]
101
+ return [f.content for f in files]
102
+ except Exception as e:
103
+ logger.error("Error accessing file content: %s", e)
104
+ raise
83
105
 
84
106
  @property
85
107
  def path(self) -> Union[str, List[str]]:
@@ -87,16 +109,27 @@ class FileInfoList(List[FileInfo]):
87
109
 
88
110
  Returns:
89
111
  Union[str, List[str]]: For a single file from file mapping, returns its path as a string.
90
- For multiple files or directory mapping, returns a list of paths.
91
-
92
- Raises:
93
- ValueError: If the list is empty
112
+ For multiple files, directory mapping, or empty list, returns a list of paths.
94
113
  """
95
- if not self:
96
- raise ValueError("No files in FileInfoList")
97
- if len(self) == 1 and not self._from_dir:
98
- return self[0].path
99
- return [f.path for f in self]
114
+ # First get a snapshot of the list state under the lock
115
+ with self._lock:
116
+ if not self:
117
+ return []
118
+ if len(self) == 1 and not self._from_dir:
119
+ file_info = self[0]
120
+ is_single = True
121
+ else:
122
+ files = list(self)
123
+ is_single = False
124
+
125
+ # Now access file paths outside the lock
126
+ try:
127
+ if is_single:
128
+ return file_info.path
129
+ return [f.path for f in files]
130
+ except Exception as e:
131
+ logger.error("Error accessing file path: %s", e)
132
+ raise
100
133
 
101
134
  @property
102
135
  def abs_path(self) -> Union[str, List[str]]:
@@ -109,11 +142,25 @@ class FileInfoList(List[FileInfo]):
109
142
  Raises:
110
143
  ValueError: If the list is empty
111
144
  """
112
- if not self:
113
- raise ValueError("No files in FileInfoList")
114
- if len(self) == 1 and not self._from_dir:
115
- return self[0].abs_path
116
- return [f.abs_path for f in self]
145
+ # First get a snapshot of the list state under the lock
146
+ with self._lock:
147
+ if not self:
148
+ raise ValueError("No files in FileInfoList")
149
+ if len(self) == 1 and not self._from_dir:
150
+ file_info = self[0]
151
+ is_single = True
152
+ else:
153
+ files = list(self)
154
+ is_single = False
155
+
156
+ # Now access file paths outside the lock
157
+ try:
158
+ if is_single:
159
+ return file_info.abs_path
160
+ return [f.abs_path for f in files]
161
+ except Exception as e:
162
+ logger.error("Error accessing absolute path: %s", e)
163
+ raise
117
164
 
118
165
  @property
119
166
  def size(self) -> Union[int, List[int]]:
@@ -126,26 +173,39 @@ class FileInfoList(List[FileInfo]):
126
173
  Raises:
127
174
  ValueError: If the list is empty or if any file size is None
128
175
  """
129
- if not self:
130
- raise ValueError("No files in FileInfoList")
131
-
132
- # For single file not from directory, return its size
133
- if len(self) == 1 and not self._from_dir:
134
- size = self[0].size
135
- if size is None:
136
- raise ValueError(
137
- f"Could not get size for file: {self[0].path}"
138
- )
139
- return size
140
-
141
- # For multiple files, collect all sizes
142
- sizes = []
143
- for f in self:
144
- size = f.size
145
- if size is None:
146
- raise ValueError(f"Could not get size for file: {f.path}")
147
- sizes.append(size)
148
- return sizes
176
+ # First get a snapshot of the list state under the lock
177
+ with self._lock:
178
+ if not self:
179
+ raise ValueError("No files in FileInfoList")
180
+
181
+ # Make a copy of the files we need to access
182
+ if len(self) == 1 and not self._from_dir:
183
+ file_info = self[0]
184
+ is_single = True
185
+ else:
186
+ files = list(self)
187
+ is_single = False
188
+
189
+ # Now access file sizes outside the lock
190
+ try:
191
+ if is_single:
192
+ size = file_info.size
193
+ if size is None:
194
+ raise ValueError(
195
+ f"Could not get size for file: {file_info.path}"
196
+ )
197
+ return size
198
+
199
+ sizes = []
200
+ for f in files:
201
+ size = f.size
202
+ if size is None:
203
+ raise ValueError(f"Could not get size for file: {f.path}")
204
+ sizes.append(size)
205
+ return sizes
206
+ except Exception as e:
207
+ logger.error("Error accessing file size: %s", e)
208
+ raise
149
209
 
150
210
  def __str__(self) -> str:
151
211
  """Get string representation of the file list.
@@ -153,11 +213,12 @@ class FileInfoList(List[FileInfo]):
153
213
  Returns:
154
214
  str: String representation in format FileInfoList([paths])
155
215
  """
156
- if not self:
157
- return "FileInfoList([])"
158
- if len(self) == 1:
159
- return f"FileInfoList(['{self[0].path}'])"
160
- return f"FileInfoList({[f.path for f in self]})"
216
+ with self._lock:
217
+ if not self:
218
+ return "FileInfoList([])"
219
+ if len(self) == 1:
220
+ return f"FileInfoList(['{self[0].path}'])"
221
+ return f"FileInfoList({[f.path for f in self]})"
161
222
 
162
223
  def __repr__(self) -> str:
163
224
  """Get detailed string representation of the file list.
@@ -169,18 +230,23 @@ class FileInfoList(List[FileInfo]):
169
230
 
170
231
  def __iter__(self) -> Iterator[FileInfo]:
171
232
  """Return iterator over files."""
233
+ with self._lock:
234
+ # Create a copy of the list to avoid concurrent modification issues
235
+ items = list(super().__iter__())
172
236
  logger.debug(
173
- "Starting iteration over FileInfoList with %d files", len(self)
237
+ "Starting iteration over FileInfoList with %d files", len(items)
174
238
  )
175
- return super().__iter__()
239
+ return iter(items)
176
240
 
177
241
  def __len__(self) -> int:
178
242
  """Return number of files."""
179
- return super().__len__()
243
+ with self._lock:
244
+ return super().__len__()
180
245
 
181
246
  def __bool__(self) -> bool:
182
247
  """Return True if there are files."""
183
- return super().__len__() > 0
248
+ with self._lock:
249
+ return super().__len__() > 0
184
250
 
185
251
  @overload
186
252
  def __getitem__(self, index: SupportsIndex, /) -> FileInfo: ...
@@ -191,17 +257,70 @@ class FileInfoList(List[FileInfo]):
191
257
  def __getitem__(
192
258
  self, index: Union[SupportsIndex, slice], /
193
259
  ) -> Union[FileInfo, "FileInfoList"]:
194
- """Get file at index."""
195
- logger.debug("Getting file at index %s", index)
196
- result = super().__getitem__(index)
197
- if isinstance(index, slice):
198
- if not isinstance(result, list):
260
+ """Get file at index.
261
+
262
+ This method is thread-safe and handles both integer indexing and slicing.
263
+ For slicing, it ensures the result is always converted to a list before
264
+ creating a new FileInfoList instance.
265
+ """
266
+ with self._lock:
267
+ logger.debug("Getting file at index %s", index)
268
+ result = super().__getitem__(index)
269
+ if isinstance(index, slice):
270
+ # Always convert to list to handle any sequence type
271
+ # Cast to Iterable[FileInfo] to satisfy mypy
272
+ result_list = list(
273
+ result if isinstance(result, list) else [result]
274
+ )
275
+ return FileInfoList(result_list, self._from_dir)
276
+ if not isinstance(result, FileInfo):
199
277
  raise TypeError(
200
- f"Expected list from slice, got {type(result)}"
278
+ f"Expected FileInfo from index, got {type(result)}"
201
279
  )
202
- return FileInfoList(result, self._from_dir)
203
- if not isinstance(result, FileInfo):
204
- raise TypeError(
205
- f"Expected FileInfo from index, got {type(result)}"
206
- )
207
- return result
280
+ return result
281
+
282
+ def append(self, value: FileInfo) -> None:
283
+ """Append a FileInfo object to the list."""
284
+ with self._lock:
285
+ super().append(value)
286
+
287
+ def extend(self, values: Iterable[FileInfo]) -> None:
288
+ """Extend the list with FileInfo objects.
289
+
290
+ Args:
291
+ values: Iterable of FileInfo objects to add
292
+ """
293
+ with self._lock:
294
+ super().extend(values)
295
+
296
+ def insert(self, index: SupportsIndex, value: FileInfo) -> None:
297
+ """Insert a FileInfo object at the given index.
298
+
299
+ Args:
300
+ index: Position to insert at
301
+ value: FileInfo object to insert
302
+ """
303
+ with self._lock:
304
+ super().insert(index, value)
305
+
306
+ def pop(self, index: SupportsIndex = -1) -> FileInfo:
307
+ """Remove and return item at index (default last).
308
+
309
+ Args:
310
+ index: Position to remove from (default: -1 for last item)
311
+
312
+ Returns:
313
+ The removed FileInfo object
314
+ """
315
+ with self._lock:
316
+ return super().pop(index)
317
+
318
+ def remove(self, value: FileInfo) -> None:
319
+ """Remove first occurrence of value."""
320
+ with self._lock:
321
+ super().remove(value)
322
+
323
+ def clear(self) -> None:
324
+ """Remove all items from list."""
325
+ with self._lock:
326
+ super().clear()