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/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
 
@@ -66,20 +74,37 @@ class FileInfoList(List[FileInfo]):
66
74
  Raises:
67
75
  ValueError: If the list is empty
68
76
  """
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:
77
+ # Take snapshot under lock
78
+ with self._lock:
79
+ if not self:
80
+ logger.debug("FileInfoList.content called but list is empty")
81
+ raise ValueError("No files in FileInfoList")
82
+
83
+ # Make a copy of the files we need to access
84
+ if len(self) == 1 and not self._from_dir:
85
+ file_info = self[0]
86
+ is_single = True
87
+ else:
88
+ files = list(self)
89
+ is_single = False
90
+
91
+ # Access file contents outside lock to prevent deadlocks
92
+ try:
93
+ if is_single:
94
+ logger.debug(
95
+ "FileInfoList.content returning single file content (not from dir)"
96
+ )
97
+ return file_info.content
98
+
73
99
  logger.debug(
74
- "FileInfoList.content returning single file content (not from dir)"
100
+ "FileInfoList.content returning list of %d contents (from_dir=%s)",
101
+ len(files),
102
+ self._from_dir,
75
103
  )
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]
104
+ return [f.content for f in files]
105
+ except Exception as e:
106
+ logger.error("Error accessing file content: %s", e)
107
+ raise
83
108
 
84
109
  @property
85
110
  def path(self) -> Union[str, List[str]]:
@@ -92,11 +117,25 @@ class FileInfoList(List[FileInfo]):
92
117
  Raises:
93
118
  ValueError: If the list is empty
94
119
  """
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]
120
+ # First get a snapshot of the list state under the lock
121
+ with self._lock:
122
+ if not self:
123
+ raise ValueError("No files in FileInfoList")
124
+ if len(self) == 1 and not self._from_dir:
125
+ file_info = self[0]
126
+ is_single = True
127
+ else:
128
+ files = list(self)
129
+ is_single = False
130
+
131
+ # Now access file paths outside the lock
132
+ try:
133
+ if is_single:
134
+ return file_info.path
135
+ return [f.path for f in files]
136
+ except Exception as e:
137
+ logger.error("Error accessing file path: %s", e)
138
+ raise
100
139
 
101
140
  @property
102
141
  def abs_path(self) -> Union[str, List[str]]:
@@ -109,11 +148,25 @@ class FileInfoList(List[FileInfo]):
109
148
  Raises:
110
149
  ValueError: If the list is empty
111
150
  """
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]
151
+ # First get a snapshot of the list state under the lock
152
+ with self._lock:
153
+ if not self:
154
+ raise ValueError("No files in FileInfoList")
155
+ if len(self) == 1 and not self._from_dir:
156
+ file_info = self[0]
157
+ is_single = True
158
+ else:
159
+ files = list(self)
160
+ is_single = False
161
+
162
+ # Now access file paths outside the lock
163
+ try:
164
+ if is_single:
165
+ return file_info.abs_path
166
+ return [f.abs_path for f in files]
167
+ except Exception as e:
168
+ logger.error("Error accessing absolute path: %s", e)
169
+ raise
117
170
 
118
171
  @property
119
172
  def size(self) -> Union[int, List[int]]:
@@ -126,26 +179,39 @@ class FileInfoList(List[FileInfo]):
126
179
  Raises:
127
180
  ValueError: If the list is empty or if any file size is None
128
181
  """
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
182
+ # First get a snapshot of the list state under the lock
183
+ with self._lock:
184
+ if not self:
185
+ raise ValueError("No files in FileInfoList")
186
+
187
+ # Make a copy of the files we need to access
188
+ if len(self) == 1 and not self._from_dir:
189
+ file_info = self[0]
190
+ is_single = True
191
+ else:
192
+ files = list(self)
193
+ is_single = False
194
+
195
+ # Now access file sizes outside the lock
196
+ try:
197
+ if is_single:
198
+ size = file_info.size
199
+ if size is None:
200
+ raise ValueError(
201
+ f"Could not get size for file: {file_info.path}"
202
+ )
203
+ return size
204
+
205
+ sizes = []
206
+ for f in files:
207
+ size = f.size
208
+ if size is None:
209
+ raise ValueError(f"Could not get size for file: {f.path}")
210
+ sizes.append(size)
211
+ return sizes
212
+ except Exception as e:
213
+ logger.error("Error accessing file size: %s", e)
214
+ raise
149
215
 
150
216
  def __str__(self) -> str:
151
217
  """Get string representation of the file list.
@@ -153,11 +219,12 @@ class FileInfoList(List[FileInfo]):
153
219
  Returns:
154
220
  str: String representation in format FileInfoList([paths])
155
221
  """
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]})"
222
+ with self._lock:
223
+ if not self:
224
+ return "FileInfoList([])"
225
+ if len(self) == 1:
226
+ return f"FileInfoList(['{self[0].path}'])"
227
+ return f"FileInfoList({[f.path for f in self]})"
161
228
 
162
229
  def __repr__(self) -> str:
163
230
  """Get detailed string representation of the file list.
@@ -169,18 +236,23 @@ class FileInfoList(List[FileInfo]):
169
236
 
170
237
  def __iter__(self) -> Iterator[FileInfo]:
171
238
  """Return iterator over files."""
239
+ with self._lock:
240
+ # Create a copy of the list to avoid concurrent modification issues
241
+ items = list(super().__iter__())
172
242
  logger.debug(
173
- "Starting iteration over FileInfoList with %d files", len(self)
243
+ "Starting iteration over FileInfoList with %d files", len(items)
174
244
  )
175
- return super().__iter__()
245
+ return iter(items)
176
246
 
177
247
  def __len__(self) -> int:
178
248
  """Return number of files."""
179
- return super().__len__()
249
+ with self._lock:
250
+ return super().__len__()
180
251
 
181
252
  def __bool__(self) -> bool:
182
253
  """Return True if there are files."""
183
- return super().__len__() > 0
254
+ with self._lock:
255
+ return super().__len__() > 0
184
256
 
185
257
  @overload
186
258
  def __getitem__(self, index: SupportsIndex, /) -> FileInfo: ...
@@ -191,17 +263,70 @@ class FileInfoList(List[FileInfo]):
191
263
  def __getitem__(
192
264
  self, index: Union[SupportsIndex, slice], /
193
265
  ) -> 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):
266
+ """Get file at index.
267
+
268
+ This method is thread-safe and handles both integer indexing and slicing.
269
+ For slicing, it ensures the result is always converted to a list before
270
+ creating a new FileInfoList instance.
271
+ """
272
+ with self._lock:
273
+ logger.debug("Getting file at index %s", index)
274
+ result = super().__getitem__(index)
275
+ if isinstance(index, slice):
276
+ # Always convert to list to handle any sequence type
277
+ # Cast to Iterable[FileInfo] to satisfy mypy
278
+ result_list = list(
279
+ result if isinstance(result, list) else [result]
280
+ )
281
+ return FileInfoList(result_list, self._from_dir)
282
+ if not isinstance(result, FileInfo):
199
283
  raise TypeError(
200
- f"Expected list from slice, got {type(result)}"
284
+ f"Expected FileInfo from index, got {type(result)}"
201
285
  )
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
286
+ return result
287
+
288
+ def append(self, value: FileInfo) -> None:
289
+ """Append a FileInfo object to the list."""
290
+ with self._lock:
291
+ super().append(value)
292
+
293
+ def extend(self, values: Iterable[FileInfo]) -> None:
294
+ """Extend the list with FileInfo objects.
295
+
296
+ Args:
297
+ values: Iterable of FileInfo objects to add
298
+ """
299
+ with self._lock:
300
+ super().extend(values)
301
+
302
+ def insert(self, index: SupportsIndex, value: FileInfo) -> None:
303
+ """Insert a FileInfo object at the given index.
304
+
305
+ Args:
306
+ index: Position to insert at
307
+ value: FileInfo object to insert
308
+ """
309
+ with self._lock:
310
+ super().insert(index, value)
311
+
312
+ def pop(self, index: SupportsIndex = -1) -> FileInfo:
313
+ """Remove and return item at index (default last).
314
+
315
+ Args:
316
+ index: Position to remove from (default: -1 for last item)
317
+
318
+ Returns:
319
+ The removed FileInfo object
320
+ """
321
+ with self._lock:
322
+ return super().pop(index)
323
+
324
+ def remove(self, value: FileInfo) -> None:
325
+ """Remove first occurrence of value."""
326
+ with self._lock:
327
+ super().remove(value)
328
+
329
+ def clear(self) -> None:
330
+ """Remove all items from list."""
331
+ with self._lock:
332
+ super().clear()
ostruct/cli/file_utils.py CHANGED
@@ -58,7 +58,7 @@ from .errors import (
58
58
  from .file_info import FileInfo
59
59
  from .file_list import FileInfoList
60
60
  from .security import SecurityManager
61
- from .security_types import SecurityManagerProtocol
61
+ from .security.types import SecurityManagerProtocol
62
62
 
63
63
  __all__ = [
64
64
  "FileInfo", # Re-exported from file_info
@@ -153,21 +153,21 @@ def collect_files_from_directory(
153
153
  allowed_extensions: Optional[List[str]] = None,
154
154
  **kwargs: Any,
155
155
  ) -> List[FileInfo]:
156
- """Collect files from directory.
156
+ """Collect files from a directory.
157
157
 
158
158
  Args:
159
159
  directory: Directory to collect files from
160
160
  security_manager: Security manager for path validation
161
- recursive: Whether to collect files recursively
162
- allowed_extensions: List of allowed file extensions without dots
161
+ recursive: Whether to process subdirectories
162
+ allowed_extensions: List of allowed file extensions (without dot)
163
163
  **kwargs: Additional arguments passed to FileInfo.from_path
164
164
 
165
165
  Returns:
166
- List of FileInfo instances
166
+ List of FileInfo objects
167
167
 
168
168
  Raises:
169
169
  DirectoryNotFoundError: If directory does not exist
170
- PathSecurityError: If directory is not allowed
170
+ PathSecurityError: If directory or any file path is not allowed
171
171
  """
172
172
  logger.debug(
173
173
  "Collecting files from directory: %s (recursive=%s, extensions=%s)",
@@ -176,86 +176,113 @@ def collect_files_from_directory(
176
176
  allowed_extensions,
177
177
  )
178
178
 
179
- # Validate directory exists and is allowed
179
+ # First validate and resolve the directory path
180
180
  try:
181
181
  abs_dir = str(security_manager.resolve_path(directory))
182
- logger.debug("Resolved absolute directory path: %s", abs_dir)
183
- logger.debug(
184
- "Security manager base_dir: %s", security_manager.base_dir
185
- )
186
- logger.debug(
187
- "Security manager allowed_dirs: %s", security_manager.allowed_dirs
188
- )
182
+ logger.debug("Resolved directory path: %s", abs_dir)
189
183
  except PathSecurityError as e:
190
- logger.debug("PathSecurityError while resolving directory: %s", str(e))
191
- # Let the original error propagate
184
+ logger.error(
185
+ "Security violation in directory path: %s (%s)", directory, str(e)
186
+ )
192
187
  raise
193
188
 
194
- if not os.path.exists(abs_dir):
195
- logger.debug("Directory not found: %s (abs: %s)", directory, abs_dir)
196
- raise DirectoryNotFoundError(f"Directory not found: {directory}")
197
189
  if not os.path.isdir(abs_dir):
198
- logger.debug(
199
- "Path is not a directory: %s (abs: %s)", directory, abs_dir
200
- )
190
+ logger.error("Path is not a directory: %s", abs_dir)
201
191
  raise DirectoryNotFoundError(f"Path is not a directory: {directory}")
202
192
 
203
- # Collect files
204
193
  files: List[FileInfo] = []
205
- for root, dirs, filenames in os.walk(abs_dir):
206
- logger.debug("Walking directory: %s", root)
207
- logger.debug("Found subdirectories: %s", dirs)
208
- logger.debug("Found files: %s", filenames)
209
194
 
210
- if not recursive and root != abs_dir:
211
- logger.debug(
212
- "Skipping subdirectory (non-recursive mode): %s", root
213
- )
214
- continue
195
+ try:
196
+ for root, dirs, filenames in os.walk(abs_dir):
197
+ logger.debug("Walking directory: %s", root)
198
+ logger.debug("Found subdirectories: %s", dirs)
199
+ logger.debug("Found files: %s", filenames)
215
200
 
216
- logger.debug("Scanning directory: %s", root)
217
- logger.debug("Current files collected: %d", len(files))
218
- for filename in filenames:
219
- # Get relative path from base directory
220
- abs_path = os.path.join(root, filename)
201
+ # Validate current directory
221
202
  try:
222
- rel_path = os.path.relpath(abs_path, security_manager.base_dir)
223
- logger.debug("Processing file: %s -> %s", abs_path, rel_path)
224
- except ValueError as e:
225
- # Skip files that can't be made relative
203
+ security_manager.validate_path(root)
204
+ except PathSecurityError as e:
205
+ logger.error(
206
+ "Security violation in subdirectory: %s (%s)", root, str(e)
207
+ )
208
+ raise
209
+
210
+ if not recursive and root != abs_dir:
226
211
  logger.debug(
227
- "Skipping file that can't be made relative: %s (error: %s)",
228
- abs_path,
229
- str(e),
212
+ "Skipping subdirectory (non-recursive mode): %s", root
230
213
  )
231
214
  continue
232
215
 
233
- # Check extension if filter is specified
234
- if allowed_extensions is not None:
235
- ext = os.path.splitext(filename)[1].lstrip(".")
236
- if ext not in allowed_extensions:
216
+ logger.debug("Scanning directory: %s", root)
217
+ logger.debug("Current files collected: %d", len(files))
218
+
219
+ for filename in filenames:
220
+ # Get relative path from base directory
221
+ abs_path = os.path.join(root, filename)
222
+ try:
223
+ rel_path = os.path.relpath(
224
+ abs_path, security_manager.base_dir
225
+ )
237
226
  logger.debug(
238
- "Skipping file with non-matching extension: %s (ext=%s, allowed=%s)",
239
- filename,
240
- ext,
241
- allowed_extensions,
227
+ "Processing file: %s -> %s", abs_path, rel_path
228
+ )
229
+ except ValueError as e:
230
+ logger.warning(
231
+ "Skipping file that can't be made relative: %s (error: %s)",
232
+ abs_path,
233
+ str(e),
242
234
  )
243
235
  continue
244
236
 
245
- try:
246
- file_info = FileInfo.from_path(
247
- rel_path, security_manager=security_manager, **kwargs
248
- )
249
- files.append(file_info)
250
- logger.debug("Added file to list: %s", rel_path)
251
- except (FileNotFoundError, PathSecurityError) as e:
252
- # Skip files that can't be accessed
253
- logger.debug(
254
- "Skipping inaccessible file: %s (error: %s)",
255
- rel_path,
256
- str(e),
257
- )
258
- continue
237
+ # Check extension if filter is specified
238
+ if allowed_extensions is not None:
239
+ ext = os.path.splitext(filename)[1].lstrip(".")
240
+ if ext not in allowed_extensions:
241
+ logger.debug(
242
+ "Skipping file with disallowed extension: %s",
243
+ filename,
244
+ )
245
+ continue
246
+
247
+ # Validate file path before creating FileInfo
248
+ try:
249
+ security_manager.validate_path(abs_path)
250
+ except PathSecurityError as e:
251
+ logger.error(
252
+ "Security violation for file: %s (%s)",
253
+ abs_path,
254
+ str(e),
255
+ )
256
+ raise
257
+
258
+ try:
259
+ file_info = FileInfo.from_path(
260
+ rel_path, security_manager=security_manager, **kwargs
261
+ )
262
+ files.append(file_info)
263
+ logger.debug("Added file to list: %s", rel_path)
264
+ except PathSecurityError as e:
265
+ # Log and re-raise security errors immediately
266
+ logger.error(
267
+ "Security violation processing file: %s (%s)",
268
+ rel_path,
269
+ str(e),
270
+ )
271
+ raise
272
+ except (FileNotFoundError, PermissionError) as e:
273
+ # Skip legitimate file access errors
274
+ logger.warning(
275
+ "Skipping inaccessible file: %s (error: %s)",
276
+ rel_path,
277
+ str(e),
278
+ )
279
+
280
+ except PathSecurityError:
281
+ # Re-raise security errors without wrapping
282
+ raise
283
+ except Exception as e:
284
+ logger.error("Error collecting files: %s", str(e))
285
+ raise
259
286
 
260
287
  logger.debug("Collected %d files from directory %s", len(files), directory)
261
288
  return files