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/__init__.py +2 -2
- ostruct/cli/cli.py +466 -604
- ostruct/cli/click_options.py +257 -0
- ostruct/cli/errors.py +234 -183
- ostruct/cli/file_info.py +154 -50
- ostruct/cli/file_list.py +189 -64
- ostruct/cli/file_utils.py +95 -67
- ostruct/cli/path_utils.py +58 -77
- ostruct/cli/security/__init__.py +32 -0
- ostruct/cli/security/allowed_checker.py +47 -0
- ostruct/cli/security/case_manager.py +75 -0
- ostruct/cli/security/errors.py +184 -0
- ostruct/cli/security/normalization.py +161 -0
- ostruct/cli/security/safe_joiner.py +211 -0
- ostruct/cli/security/security_manager.py +353 -0
- ostruct/cli/security/symlink_resolver.py +483 -0
- ostruct/cli/security/types.py +108 -0
- ostruct/cli/security/windows_paths.py +404 -0
- ostruct/cli/template_filters.py +8 -5
- ostruct/cli/template_io.py +4 -2
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/METADATA +9 -6
- ostruct_cli-0.4.0.dist-info/RECORD +36 -0
- ostruct/cli/security.py +0 -323
- ostruct/cli/security_types.py +0 -49
- ostruct_cli-0.2.0.dist-info/RECORD +0 -27
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.2.0.dist-info → ostruct_cli-0.4.0.dist-info}/entry_points.txt +0 -0
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 =
|
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
|
-
|
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
|
-
|
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
|
-
|
86
|
-
|
87
|
-
logger.debug("
|
88
|
-
|
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
|
-
|
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
|
-
|
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
|
-
)
|
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
|
-
)
|
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
|
-
|
212
|
-
|
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
|
-
|
215
|
-
|
216
|
-
|
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
|
-
|
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.
|
226
|
-
|
227
|
-
|
228
|
-
except
|
229
|
-
|
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
|
-
|
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.
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
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
|
-
|
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
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
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
|
100
|
+
"FileInfoList.content returning list of %d contents (from_dir=%s)",
|
101
|
+
len(files),
|
102
|
+
self._from_dir,
|
75
103
|
)
|
76
|
-
return
|
77
|
-
|
78
|
-
"
|
79
|
-
|
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
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
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
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
if
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
sizes
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
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
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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(
|
243
|
+
"Starting iteration over FileInfoList with %d files", len(items)
|
174
244
|
)
|
175
|
-
return
|
245
|
+
return iter(items)
|
176
246
|
|
177
247
|
def __len__(self) -> int:
|
178
248
|
"""Return number of files."""
|
179
|
-
|
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
|
-
|
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
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
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
|
284
|
+
f"Expected FileInfo from index, got {type(result)}"
|
201
285
|
)
|
202
|
-
return
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
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()
|