ostruct-cli 0.2.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_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,31 +62,67 @@ class FileInfo:
61
62
  self.__size: Optional[int] = None
62
63
  self.__mtime: Optional[float] = None
63
64
 
64
- # First check if file exists
65
- abs_path = os.path.abspath(self.__path)
66
- logger.debug("Absolute path for %s: %s", path, abs_path)
67
-
68
- if not os.path.exists(abs_path):
69
- raise FileNotFoundError(f"File not found: {path}")
70
- if not os.path.isfile(abs_path):
71
- raise FileNotFoundError(f"Path is not a file: {path}")
72
-
73
- # Then validate security
74
65
  try:
75
66
  # This will raise PathSecurityError if path is not allowed
76
- resolved_path = self.abs_path
67
+ # And FileNotFoundError if the file doesn't exist
68
+ resolved_path = self.__security_manager.resolve_path(self.__path)
77
69
  logger.debug(
78
70
  "Security-resolved path for %s: %s", path, resolved_path
79
71
  )
80
- except PathSecurityError:
72
+
73
+ # Check if it's a regular file (not a directory, device, etc.)
74
+ if not resolved_path.is_file():
75
+ logger.debug("Not a regular file: %s", resolved_path)
76
+ raise FileNotFoundError(
77
+ f"Not a regular file: {os.path.basename(str(path))}"
78
+ )
79
+
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
+ },
98
+ )
81
99
  raise
82
- except Exception as e:
83
- raise FileNotFoundError(f"Invalid file path: {e}")
84
100
 
85
- # If content/encoding weren't provided, read them now
86
- if self.__content is None or self.__encoding is None:
87
- logger.debug("Reading content for %s", path)
88
- self._read_file()
101
+ except FileNotFoundError as e:
102
+ # Re-raise with standardized message format
103
+ logger.debug("File not found error: %s", e)
104
+ raise FileNotFoundError(
105
+ f"File not found: {os.path.basename(str(path))}"
106
+ ) from e
107
+
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
89
126
 
90
127
  @property
91
128
  def path(self) -> str:
@@ -141,7 +178,8 @@ class FileInfo:
141
178
  """Get file size in bytes."""
142
179
  if self.__size is None:
143
180
  try:
144
- self.__size = os.path.getsize(self.abs_path)
181
+ size = os.path.getsize(self.abs_path)
182
+ self.__size = size
145
183
  except OSError:
146
184
  logger.warning("Could not get size for %s", self.__path)
147
185
  return None
@@ -157,7 +195,8 @@ class FileInfo:
157
195
  """Get file modification time as Unix timestamp."""
158
196
  if self.__mtime is None:
159
197
  try:
160
- self.__mtime = os.path.getmtime(self.abs_path)
198
+ mtime = os.path.getmtime(self.abs_path)
199
+ self.__mtime = mtime
161
200
  except OSError:
162
201
  logger.warning("Could not get mtime for %s", self.__path)
163
202
  return None
@@ -170,10 +209,25 @@ class FileInfo:
170
209
 
171
210
  @property
172
211
  def content(self) -> str:
173
- """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
174
228
  assert (
175
229
  self.__content is not None
176
- ), "Content should be initialized in constructor"
230
+ ) # Help mypy understand content is set
177
231
  return self.__content
178
232
 
179
233
  @content.setter
@@ -183,10 +237,20 @@ class FileInfo:
183
237
 
184
238
  @property
185
239
  def encoding(self) -> str:
186
- """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
187
251
  assert (
188
252
  self.__encoding is not None
189
- ), "Encoding should be initialized in constructor"
253
+ ) # Help mypy understand encoding is set
190
254
  return self.__encoding
191
255
 
192
256
  @encoding.setter
@@ -208,35 +272,51 @@ class FileInfo:
208
272
  """Prevent setting hash directly."""
209
273
  raise AttributeError("Cannot modify hash directly")
210
274
 
211
- def _read_file(self) -> None:
212
- """Read file content and encoding from disk."""
275
+ @property
276
+ def exists(self) -> bool:
277
+ """Check if the file exists.
278
+
279
+ Returns:
280
+ bool: True if the file exists, False otherwise
281
+ """
213
282
  try:
214
- with open(self.abs_path, "rb") as f:
215
- raw_content = f.read()
216
- except FileNotFoundError as e:
217
- raise FileNotFoundError(f"File not found: {self.__path}") from e
218
- except OSError as e:
219
- raise FileNotFoundError(
220
- f"Could not read file {self.__path}: {e}"
221
- ) from e
283
+ return os.path.exists(self.abs_path)
284
+ except (OSError, PathSecurityError):
285
+ return False
222
286
 
223
- # Try UTF-8 first
287
+ @property
288
+ def is_binary(self) -> bool:
289
+ """Check if the file appears to be binary.
290
+
291
+ Returns:
292
+ bool: True if the file appears to be binary, False otherwise
293
+ """
224
294
  try:
225
- self.__content = raw_content.decode("utf-8")
226
- self.__encoding = "utf-8"
227
- return
228
- except UnicodeDecodeError:
229
- pass
295
+ with open(self.abs_path, "rb") as f:
296
+ chunk = f.read(1024)
297
+ return b"\0" in chunk
298
+ except (OSError, PathSecurityError):
299
+ return False
230
300
 
231
- # Fall back to system default encoding
301
+ def _read_file(self) -> None:
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
+ """
232
307
  try:
233
- self.__content = raw_content.decode()
234
- self.__encoding = "system"
235
- return
236
- except UnicodeDecodeError as e:
237
- raise ValueError(
238
- f"Could not decode file {self.__path}: {e}"
239
- ) from e
308
+ with open(self.abs_path, "rb") as f:
309
+ raw_content = f.read()
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
240
320
 
241
321
  def update_cache(
242
322
  self,
@@ -322,3 +402,27 @@ class FileInfo:
322
402
  )
323
403
  finally:
324
404
  del frame # Avoid reference cycles
405
+
406
+ def to_dict(self) -> dict[str, Any]:
407
+ """Convert file info to a dictionary.
408
+
409
+ Returns:
410
+ Dictionary containing file metadata and content
411
+ """
412
+ # Get file stats
413
+ stats = os.stat(self.abs_path)
414
+
415
+ return {
416
+ "path": self.path,
417
+ "abs_path": str(self.abs_path),
418
+ "exists": self.exists,
419
+ "size": self.size,
420
+ "content": self.content,
421
+ "encoding": self.encoding,
422
+ "hash": self.hash,
423
+ "mtime": self.mtime,
424
+ "mtime_ns": (
425
+ int(self.mtime * 1e9) if self.mtime is not None else None
426
+ ),
427
+ "mode": stats.st_mode,
428
+ }
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()