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.
- ostruct/cli/base_errors.py +183 -0
- ostruct/cli/cli.py +830 -585
- ostruct/cli/click_options.py +338 -211
- ostruct/cli/errors.py +214 -227
- ostruct/cli/exit_codes.py +18 -0
- ostruct/cli/file_info.py +126 -69
- ostruct/cli/file_list.py +191 -72
- ostruct/cli/file_utils.py +132 -97
- ostruct/cli/path_utils.py +86 -77
- ostruct/cli/security/__init__.py +32 -0
- ostruct/cli/security/allowed_checker.py +55 -0
- ostruct/cli/security/base.py +46 -0
- ostruct/cli/security/case_manager.py +75 -0
- ostruct/cli/security/errors.py +164 -0
- ostruct/cli/security/normalization.py +161 -0
- ostruct/cli/security/safe_joiner.py +211 -0
- ostruct/cli/security/security_manager.py +366 -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/serialization.py +25 -0
- ostruct/cli/template_filters.py +13 -8
- ostruct/cli/template_rendering.py +46 -22
- ostruct/cli/template_utils.py +12 -4
- ostruct/cli/template_validation.py +26 -8
- ostruct/cli/token_utils.py +43 -0
- ostruct/cli/validators.py +109 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/METADATA +64 -24
- ostruct_cli-0.5.0.dist-info/RECORD +42 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/WHEEL +1 -1
- ostruct/cli/security.py +0 -964
- ostruct/cli/security_types.py +0 -46
- ostruct_cli-0.3.0.dist-info/RECORD +0 -28
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/LICENSE +0 -0
- {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
|
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 =
|
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
|
-
#
|
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
|
-
|
81
|
-
|
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
|
-
#
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
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
|
-
|
99
|
-
|
100
|
-
logger.debug("
|
101
|
-
|
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
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
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
|
-
|
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
|
-
|
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
|
-
)
|
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
|
-
)
|
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
|
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
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
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
|
-
|
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
|
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
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
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
|
97
|
+
"FileInfoList.content returning list of %d contents (from_dir=%s)",
|
98
|
+
len(files),
|
99
|
+
self._from_dir,
|
75
100
|
)
|
76
|
-
return
|
77
|
-
|
78
|
-
"
|
79
|
-
|
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
|
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
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
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
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
if
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
sizes
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
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
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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(
|
237
|
+
"Starting iteration over FileInfoList with %d files", len(items)
|
174
238
|
)
|
175
|
-
return
|
239
|
+
return iter(items)
|
176
240
|
|
177
241
|
def __len__(self) -> int:
|
178
242
|
"""Return number of files."""
|
179
|
-
|
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
|
-
|
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
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
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
|
278
|
+
f"Expected FileInfo from index, got {type(result)}"
|
201
279
|
)
|
202
|
-
return
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
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()
|