ostruct-cli 0.7.2__py3-none-any.whl → 0.8.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 (46) hide show
  1. ostruct/cli/__init__.py +21 -3
  2. ostruct/cli/base_errors.py +1 -1
  3. ostruct/cli/cli.py +66 -1983
  4. ostruct/cli/click_options.py +460 -28
  5. ostruct/cli/code_interpreter.py +238 -0
  6. ostruct/cli/commands/__init__.py +32 -0
  7. ostruct/cli/commands/list_models.py +128 -0
  8. ostruct/cli/commands/quick_ref.py +50 -0
  9. ostruct/cli/commands/run.py +137 -0
  10. ostruct/cli/commands/update_registry.py +71 -0
  11. ostruct/cli/config.py +277 -0
  12. ostruct/cli/cost_estimation.py +134 -0
  13. ostruct/cli/errors.py +310 -6
  14. ostruct/cli/exit_codes.py +1 -0
  15. ostruct/cli/explicit_file_processor.py +548 -0
  16. ostruct/cli/field_utils.py +69 -0
  17. ostruct/cli/file_info.py +42 -9
  18. ostruct/cli/file_list.py +301 -102
  19. ostruct/cli/file_search.py +455 -0
  20. ostruct/cli/file_utils.py +47 -13
  21. ostruct/cli/mcp_integration.py +541 -0
  22. ostruct/cli/model_creation.py +150 -1
  23. ostruct/cli/model_validation.py +204 -0
  24. ostruct/cli/progress_reporting.py +398 -0
  25. ostruct/cli/registry_updates.py +14 -9
  26. ostruct/cli/runner.py +1418 -0
  27. ostruct/cli/schema_utils.py +113 -0
  28. ostruct/cli/services.py +626 -0
  29. ostruct/cli/template_debug.py +748 -0
  30. ostruct/cli/template_debug_help.py +162 -0
  31. ostruct/cli/template_env.py +15 -6
  32. ostruct/cli/template_filters.py +55 -3
  33. ostruct/cli/template_optimizer.py +474 -0
  34. ostruct/cli/template_processor.py +1080 -0
  35. ostruct/cli/template_rendering.py +69 -34
  36. ostruct/cli/token_validation.py +286 -0
  37. ostruct/cli/types.py +78 -0
  38. ostruct/cli/unattended_operation.py +269 -0
  39. ostruct/cli/validators.py +386 -3
  40. {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/LICENSE +2 -0
  41. ostruct_cli-0.8.0.dist-info/METADATA +633 -0
  42. ostruct_cli-0.8.0.dist-info/RECORD +69 -0
  43. {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/WHEEL +1 -1
  44. ostruct_cli-0.7.2.dist-info/METADATA +0 -370
  45. ostruct_cli-0.7.2.dist-info/RECORD +0 -45
  46. {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/file_info.py CHANGED
@@ -24,6 +24,7 @@ class FileInfo:
24
24
  content: Optional cached content
25
25
  encoding: Optional cached encoding
26
26
  hash_value: Optional cached hash value
27
+ routing_type: How the file was routed (e.g., 'template', 'code-interpreter')
27
28
  """
28
29
 
29
30
  def __init__(
@@ -33,6 +34,7 @@ class FileInfo:
33
34
  content: Optional[str] = None,
34
35
  encoding: Optional[str] = None,
35
36
  hash_value: Optional[str] = None,
37
+ routing_type: Optional[str] = None,
36
38
  ) -> None:
37
39
  """Initialize FileInfo instance.
38
40
 
@@ -42,19 +44,13 @@ class FileInfo:
42
44
  content: Optional cached content
43
45
  encoding: Optional cached encoding
44
46
  hash_value: Optional cached hash value
47
+ routing_type: How the file was routed (e.g., 'template', 'code-interpreter')
45
48
 
46
49
  Raises:
47
50
  FileNotFoundError: If the file does not exist
48
51
  PathSecurityError: If the path is not allowed
49
52
  PermissionError: If access is denied
50
53
  """
51
- logger.debug("Creating FileInfo for path: %s", path)
52
-
53
- # Validate path
54
- if not path:
55
- raise ValueError("Path cannot be empty")
56
-
57
- # Initialize private attributes
58
54
  self.__path = str(path)
59
55
  self.__security_manager = security_manager
60
56
  self.__content = content
@@ -62,6 +58,17 @@ class FileInfo:
62
58
  self.__hash = hash_value
63
59
  self.__size: Optional[int] = None
64
60
  self.__mtime: Optional[float] = None
61
+ self.routing_type = routing_type
62
+
63
+ logger.debug(
64
+ "Creating FileInfo for path: %s, routing_type: %s",
65
+ path,
66
+ self.routing_type,
67
+ )
68
+
69
+ # Validate path
70
+ if not path:
71
+ raise ValueError("Path cannot be empty")
65
72
 
66
73
  try:
67
74
  # This will raise PathSecurityError if path is not allowed
@@ -125,6 +132,23 @@ class FileInfo:
125
132
  f"Permission denied: {os.path.basename(str(path))}"
126
133
  ) from e
127
134
 
135
+ # Add warning for large template-only files accessed via .content
136
+ # Check if routing_type is 'template' or if it's part of a legacy -f/-d mapping
137
+ # For simplicity now, let's assume if routing_type is None it could be legacy template
138
+ is_template_routed = (
139
+ self.routing_type == "template" or self.routing_type is None
140
+ )
141
+ if (
142
+ is_template_routed and self.size and self.size > 100 * 1024
143
+ ): # 100KB threshold
144
+ logger.warning(
145
+ f"File '{self.path}' ({self.size / 1024:.1f}KB) was routed for template-only access "
146
+ f"but its .content is being accessed. This will include the entire file content "
147
+ f"in the prompt sent to the AI. For large files intended for analysis or search, "
148
+ f"consider using -fc (Code Interpreter) or -fs (File Search) to optimize token usage, "
149
+ f"cost, and avoid exceeding model context limits."
150
+ )
151
+
128
152
  @property
129
153
  def path(self) -> str:
130
154
  """Get the path relative to security manager's base directory.
@@ -356,13 +380,17 @@ class FileInfo:
356
380
 
357
381
  @classmethod
358
382
  def from_path(
359
- cls, path: str, security_manager: SecurityManager
383
+ cls,
384
+ path: str,
385
+ security_manager: SecurityManager,
386
+ routing_type: Optional[str] = None,
360
387
  ) -> "FileInfo":
361
388
  """Create FileInfo instance from path.
362
389
 
363
390
  Args:
364
391
  path: Path to file
365
392
  security_manager: Security manager for path validation
393
+ routing_type: How the file was routed (e.g., 'template', 'code-interpreter')
366
394
 
367
395
  Returns:
368
396
  FileInfo instance
@@ -371,7 +399,7 @@ class FileInfo:
371
399
  FileNotFoundError: If file does not exist
372
400
  PathSecurityError: If path is not allowed
373
401
  """
374
- return cls(path, security_manager)
402
+ return cls(path, security_manager, routing_type=routing_type)
375
403
 
376
404
  def __str__(self) -> str:
377
405
  """String representation showing path."""
@@ -391,6 +419,11 @@ class FileInfo:
391
419
 
392
420
  Internal methods can modify private attributes, but external access is prevented.
393
421
  """
422
+ # Allow setting routing_type if it's not already set (i.e., during __init__)
423
+ if name == "routing_type" and not hasattr(self, name):
424
+ object.__setattr__(self, name, value)
425
+ return
426
+
394
427
  # Allow setting private attributes from internal methods
395
428
  if name.startswith("_FileInfo__") and self._is_internal_call():
396
429
  object.__setattr__(self, name, value)
ostruct/cli/file_list.py CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  import logging
4
4
  import threading
5
- from typing import Iterable, Iterator, List, SupportsIndex, Union, overload
5
+ from typing import (
6
+ Iterable,
7
+ Iterator,
8
+ List,
9
+ Optional,
10
+ SupportsIndex,
11
+ Union,
12
+ overload,
13
+ )
6
14
 
7
15
  from .file_info import FileInfo
8
16
 
@@ -12,12 +20,15 @@ logger = logging.getLogger(__name__)
12
20
 
13
21
 
14
22
  class FileInfoList(List[FileInfo]):
15
- """List of FileInfo objects with smart content access.
23
+ """List of FileInfo objects with strict single-file content access.
16
24
 
17
25
  This class extends List[FileInfo] to provide convenient access to file contents
18
- and metadata. When the list contains exactly one file from a single file mapping,
19
- properties like content return the value directly. For multiple files or directory
20
- mappings, properties return a list of values.
26
+ and metadata. Properties like content, path, name, etc. are designed for single-file
27
+ access only and will raise ValueError for multiple files or directory mappings.
28
+
29
+ This prevents accidental data exposure and template errors by requiring explicit
30
+ handling of multi-file scenarios through indexing (files[0].content) or the
31
+ |single filter (files|single.content).
21
32
 
22
33
  This class is thread-safe. All operations that access or modify the internal list
23
34
  are protected by a reentrant lock (RLock). This allows nested method calls while
@@ -30,195 +41,383 @@ class FileInfoList(List[FileInfo]):
30
41
  files = FileInfoList([file_info], from_dir=False)
31
42
  content = files.content # Returns "file contents"
32
43
 
33
- Multiple files or directory (--files or --dir):
44
+ Multiple files or directory (raises error):
34
45
  files = FileInfoList([file1, file2]) # or FileInfoList([file1], from_dir=True)
35
- content = files.content # Returns ["contents1", "contents2"] or ["contents1"]
46
+ content = files.content # Raises ValueError with helpful message
36
47
 
37
- Backward compatibility:
38
- content = files[0].content # Still works
48
+ Safe multi-file access:
49
+ content = files[0].content # Access first file explicitly
50
+ content = files|single.content # Use |single filter for validation
39
51
 
40
52
  Properties:
41
- content: File content(s) - string for single file mapping, list for multiple files or directory
42
- path: File path(s)
43
- abs_path: Absolute file path(s)
44
- size: File size(s) in bytes
53
+ content: File content - only for single file from file mapping (not directory)
54
+ path: File path - only for single file from file mapping
55
+ abs_path: Absolute file path - only for single file from file mapping
56
+ size: File size in bytes - only for single file from file mapping
57
+ name: Filename without directory path - only for single file from file mapping
58
+ names: Always returns list of all filenames (safe for multi-file access)
45
59
 
46
60
  Raises:
47
- ValueError: When accessing properties on an empty list
61
+ ValueError: When accessing scalar properties on empty list, multiple files, or directory mappings
48
62
  """
49
63
 
50
- def __init__(self, files: List[FileInfo], from_dir: bool = False) -> None:
64
+ def __init__(
65
+ self,
66
+ files: List[FileInfo],
67
+ from_dir: bool = False,
68
+ var_alias: Optional[str] = None,
69
+ ) -> None:
51
70
  """Initialize FileInfoList.
52
71
 
53
72
  Args:
54
73
  files: List of FileInfo objects
55
74
  from_dir: Whether this list was created from a directory mapping
75
+ var_alias: Variable name used in template (for error messages)
56
76
  """
57
77
  logger.debug(
58
- "Creating FileInfoList with %d files, from_dir=%s",
78
+ "Creating FileInfoList with %d files, from_dir=%s, var_alias=%s",
59
79
  len(files),
60
80
  from_dir,
81
+ var_alias,
61
82
  )
62
83
  self._lock = threading.RLock() # Use RLock for nested method calls
63
84
  super().__init__(files)
64
85
  self._from_dir = from_dir
86
+ self._var_alias = var_alias
65
87
 
66
88
  @property
67
- def content(self) -> Union[str, List[str]]:
68
- """Get the content of the file(s).
89
+ def content(self) -> str:
90
+ """Get the content of a single file.
69
91
 
70
92
  Returns:
71
- Union[str, List[str]]: For a single file from file mapping, returns its content as a string.
72
- For multiple files, directory mapping, or empty list, returns a list of contents.
93
+ str: Content of the single file from file mapping.
94
+
95
+ Raises:
96
+ ValueError: If the list is empty or contains multiple files.
73
97
  """
74
98
  # Take snapshot under lock
75
99
  with self._lock:
76
100
  if not self:
77
- logger.debug("FileInfoList.content called but list is empty")
78
- return []
101
+ var_name = self._var_alias or "file_list"
102
+ raise ValueError(
103
+ f"No files in '{var_name}'. Cannot access .content property."
104
+ )
79
105
 
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
106
+ # Check for multiple files or directory mapping
107
+ if len(self) > 1:
108
+ var_name = self._var_alias or "file_list"
109
+ raise ValueError(
110
+ f"'{var_name}' contains {len(self)} files. "
111
+ f"Use '{{{{ {var_name}[0].content }}}}' for the first file, "
112
+ f"'{{{{ {var_name}|single.content }}}}' if expecting exactly one file, "
113
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.content }}}}{{%% endfor %%}}'."
114
+ )
87
115
 
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)"
116
+ if self._from_dir:
117
+ var_name = self._var_alias or "file_list"
118
+ raise ValueError(
119
+ f"'{var_name}' contains files from directory mapping. "
120
+ f"Use '{{{{ {var_name}[0].content }}}}' for the first file, "
121
+ f"'{{{{ {var_name}|single.content }}}}' if expecting exactly one file, "
122
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.content }}}}{{%% endfor %%}}'."
93
123
  )
94
- return file_info.content
95
124
 
125
+ # Single file from file mapping
126
+ file_info = self[0]
127
+
128
+ # Access file content outside lock to prevent deadlocks
129
+ try:
96
130
  logger.debug(
97
- "FileInfoList.content returning list of %d contents (from_dir=%s)",
98
- len(files),
99
- self._from_dir,
131
+ "FileInfoList.content returning single file content (not from dir)"
100
132
  )
101
- return [f.content for f in files]
133
+ return file_info.content
102
134
  except Exception as e:
103
135
  logger.error("Error accessing file content: %s", e)
104
136
  raise
105
137
 
106
138
  @property
107
- def path(self) -> Union[str, List[str]]:
108
- """Get the path of the file(s).
139
+ def path(self) -> str:
140
+ """Get the path of a single file.
109
141
 
110
142
  Returns:
111
- Union[str, List[str]]: For a single file from file mapping, returns its path as a string.
112
- For multiple files, directory mapping, or empty list, returns a list of paths.
143
+ str: Path of the single file from file mapping.
144
+
145
+ Raises:
146
+ ValueError: If the list is empty or contains multiple files.
113
147
  """
114
148
  # First get a snapshot of the list state under the lock
115
149
  with self._lock:
116
150
  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
151
+ var_name = self._var_alias or "file_list"
152
+ raise ValueError(
153
+ f"No files in '{var_name}'. Cannot access .path property."
154
+ )
124
155
 
125
- # Now access file paths outside the lock
156
+ # Check for multiple files or directory mapping
157
+ if len(self) > 1:
158
+ var_name = self._var_alias or "file_list"
159
+ raise ValueError(
160
+ f"'{var_name}' contains {len(self)} files. "
161
+ f"Use '{{{{ {var_name}[0].path }}}}' for the first file, "
162
+ f"'{{{{ {var_name}|single.path }}}}' if expecting exactly one file, "
163
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.path }}}}{{%% endfor %%}}'."
164
+ )
165
+
166
+ if self._from_dir:
167
+ var_name = self._var_alias or "file_list"
168
+ raise ValueError(
169
+ f"'{var_name}' contains files from directory mapping. "
170
+ f"Use '{{{{ {var_name}[0].path }}}}' for the first file, "
171
+ f"'{{{{ {var_name}|single.path }}}}' if expecting exactly one file, "
172
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.path }}}}{{%% endfor %%}}'."
173
+ )
174
+
175
+ # Single file from file mapping
176
+ file_info = self[0]
177
+
178
+ # Now access file path outside the lock
126
179
  try:
127
- if is_single:
128
- return file_info.path
129
- return [f.path for f in files]
180
+ return file_info.path
130
181
  except Exception as e:
131
182
  logger.error("Error accessing file path: %s", e)
132
183
  raise
133
184
 
134
185
  @property
135
- def abs_path(self) -> Union[str, List[str]]:
136
- """Get the absolute path of the file(s).
186
+ def abs_path(self) -> str:
187
+ """Get the absolute path of a single file.
137
188
 
138
189
  Returns:
139
- Union[str, List[str]]: For a single file from file mapping, returns its absolute path as a string.
140
- For multiple files or directory mapping, returns a list of absolute paths.
190
+ str: Absolute path of the single file from file mapping.
141
191
 
142
192
  Raises:
143
- ValueError: If the list is empty
193
+ ValueError: If the list is empty or contains multiple files.
144
194
  """
145
195
  # First get a snapshot of the list state under the lock
146
196
  with self._lock:
147
197
  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
198
+ var_name = self._var_alias or "file_list"
199
+ raise ValueError(
200
+ f"No files in '{var_name}'. Cannot access .abs_path property."
201
+ )
155
202
 
156
- # Now access file paths outside the lock
203
+ # Check for multiple files or directory mapping
204
+ if len(self) > 1:
205
+ var_name = self._var_alias or "file_list"
206
+ raise ValueError(
207
+ f"'{var_name}' contains {len(self)} files. "
208
+ f"Use '{{{{ {var_name}[0].abs_path }}}}' for the first file, "
209
+ f"'{{{{ {var_name}|single.abs_path }}}}' if expecting exactly one file, "
210
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.abs_path }}}}{{%% endfor %%}}'."
211
+ )
212
+
213
+ if self._from_dir:
214
+ var_name = self._var_alias or "file_list"
215
+ raise ValueError(
216
+ f"'{var_name}' contains files from directory mapping. "
217
+ f"Use '{{{{ {var_name}[0].abs_path }}}}' for the first file, "
218
+ f"'{{{{ {var_name}|single.abs_path }}}}' if expecting exactly one file, "
219
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.abs_path }}}}{{%% endfor %%}}'."
220
+ )
221
+
222
+ # Single file from file mapping
223
+ file_info = self[0]
224
+
225
+ # Now access file path outside the lock
157
226
  try:
158
- if is_single:
159
- return file_info.abs_path
160
- return [f.abs_path for f in files]
227
+ return file_info.abs_path
161
228
  except Exception as e:
162
229
  logger.error("Error accessing absolute path: %s", e)
163
230
  raise
164
231
 
165
232
  @property
166
- def size(self) -> Union[int, List[int]]:
167
- """Get file size(s) in bytes.
233
+ def size(self) -> int:
234
+ """Get file size of a single file in bytes.
168
235
 
169
236
  Returns:
170
- Union[int, List[int]]: For a single file from file mapping, returns its size in bytes.
171
- For multiple files or directory mapping, returns a list of sizes.
237
+ int: Size of the single file from file mapping in bytes.
172
238
 
173
239
  Raises:
174
- ValueError: If the list is empty or if any file size is None
240
+ ValueError: If the list is empty, contains multiple files, or file size is None.
175
241
  """
176
242
  # First get a snapshot of the list state under the lock
177
243
  with self._lock:
178
244
  if not self:
179
- raise ValueError("No files in FileInfoList")
245
+ var_name = self._var_alias or "file_list"
246
+ raise ValueError(
247
+ f"No files in '{var_name}'. Cannot access .size property."
248
+ )
180
249
 
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
250
+ # Check for multiple files or directory mapping
251
+ if len(self) > 1:
252
+ var_name = self._var_alias or "file_list"
253
+ raise ValueError(
254
+ f"'{var_name}' contains {len(self)} files. "
255
+ f"Use '{{{{ {var_name}[0].size }}}}' for the first file, "
256
+ f"'{{{{ {var_name}|single.size }}}}' if expecting exactly one file, "
257
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.size }}}}{{%% endfor %%}}'."
258
+ )
188
259
 
189
- # Now access file sizes outside the lock
260
+ if self._from_dir:
261
+ var_name = self._var_alias or "file_list"
262
+ raise ValueError(
263
+ f"'{var_name}' contains files from directory mapping. "
264
+ f"Use '{{{{ {var_name}[0].size }}}}' for the first file, "
265
+ f"'{{{{ {var_name}|single.size }}}}' if expecting exactly one file, "
266
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.size }}}}{{%% endfor %%}}'."
267
+ )
268
+
269
+ # Single file from file mapping
270
+ file_info = self[0]
271
+
272
+ # Now access file size outside the lock
190
273
  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
274
+ size = file_info.size
275
+ if size is None:
276
+ raise ValueError(
277
+ f"Could not get size for file: {file_info.path}"
278
+ )
279
+ return size
206
280
  except Exception as e:
207
281
  logger.error("Error accessing file size: %s", e)
208
282
  raise
209
283
 
284
+ @property
285
+ def name(self) -> str:
286
+ """Get the filename of a single file without directory path.
287
+
288
+ Returns:
289
+ str: Name of the single file from file mapping.
290
+
291
+ Raises:
292
+ ValueError: If the list is empty or contains multiple files.
293
+ """
294
+ with self._lock:
295
+ if not self:
296
+ var_name = self._var_alias or "file_list"
297
+ raise ValueError(
298
+ f"No files in '{var_name}'. Cannot access .name property."
299
+ )
300
+
301
+ # Check for multiple files or directory mapping
302
+ if len(self) > 1:
303
+ var_name = self._var_alias or "file_list"
304
+ raise ValueError(
305
+ f"'{var_name}' contains {len(self)} files. "
306
+ f"Use '{{{{ {var_name}[0].name }}}}' for the first file, "
307
+ f"'{{{{ {var_name}|single.name }}}}' if expecting exactly one file, "
308
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.name }}}}{{%% endfor %%}}'."
309
+ )
310
+
311
+ if self._from_dir:
312
+ var_name = self._var_alias or "file_list"
313
+ raise ValueError(
314
+ f"'{var_name}' contains files from directory mapping. "
315
+ f"Use '{{{{ {var_name}[0].name }}}}' for the first file, "
316
+ f"'{{{{ {var_name}|single.name }}}}' if expecting exactly one file, "
317
+ f"or loop over files with '{{%% for file in {var_name} %%}}{{{{ file.name }}}}{{%% endfor %%}}'."
318
+ )
319
+
320
+ # Single file from file mapping
321
+ return self[0].name
322
+
323
+ @property
324
+ def names(self) -> List[str]:
325
+ """Get all filenames as a list."""
326
+ with self._lock:
327
+ if not self:
328
+ return []
329
+ try:
330
+ return [f.name for f in self]
331
+ except Exception as e:
332
+ logger.error(
333
+ f"Error accessing file names for .names property in '{self._var_alias or 'FileInfoList'}': {e}"
334
+ )
335
+ raise
336
+
337
+ def __getattr__(self, attr_name: str) -> None:
338
+ """Provide helpful error messages for FileInfo attributes accessed on multi-file lists."""
339
+ # Import here to avoid circular imports
340
+ try:
341
+ from .template_schema import FileInfoProxy
342
+
343
+ # Try to get _valid_attrs as a class attribute, but it's actually an instance attribute
344
+ # So we'll get an empty set and fall back to our hardcoded list
345
+ valid_attrs = getattr(FileInfoProxy, "_valid_attrs", None)
346
+ if not valid_attrs:
347
+ # Use the same attributes that FileInfoProxy uses
348
+ valid_attrs = {
349
+ "name",
350
+ "path",
351
+ "content",
352
+ "ext",
353
+ "basename",
354
+ "dirname",
355
+ "abs_path",
356
+ "exists",
357
+ "is_file",
358
+ "is_dir",
359
+ "size",
360
+ "mtime",
361
+ "encoding",
362
+ "hash",
363
+ "extension",
364
+ "parent",
365
+ "stem",
366
+ "suffix",
367
+ }
368
+ except ImportError:
369
+ # Fallback list of common FileInfo attributes
370
+ valid_attrs = {
371
+ "name",
372
+ "path",
373
+ "content",
374
+ "size",
375
+ "abs_path",
376
+ "exists",
377
+ "encoding",
378
+ "hash",
379
+ }
380
+
381
+ # Only provide enhanced errors for known FileInfo attributes
382
+ if attr_name in valid_attrs:
383
+ # Check if this is a non-scalar list trying to access FileInfo attributes
384
+ if len(self) != 1 or self._from_dir:
385
+ var_name = getattr(self, "_var_alias", None) or "file_list"
386
+ raise AttributeError(
387
+ f"'{var_name}' contains {len(self)} files. "
388
+ f"Use '{{{{ {var_name}[0].{attr_name} }}}}' for the first file, "
389
+ f"or '{{{{ {var_name}|single.{attr_name} }}}}' if expecting exactly one file."
390
+ )
391
+
392
+ # Let the default AttributeError occur for truly missing attributes
393
+ raise AttributeError(
394
+ f"'{type(self).__name__}' object has no attribute '{attr_name}'"
395
+ )
396
+
210
397
  def __str__(self) -> str:
211
398
  """Get string representation of the file list.
212
399
 
213
400
  Returns:
214
- str: String representation in format FileInfoList([paths])
401
+ str: Helpful guidance message for template usage
215
402
  """
216
403
  with self._lock:
217
404
  if not self:
218
405
  return "FileInfoList([])"
406
+
407
+ # For single file from file mapping (--fta, -ft, etc.)
408
+ if len(self) == 1 and not self._from_dir:
409
+ var_name = self._var_alias or "file_var"
410
+ return f"[File '{self[0].path}' - Use {{ {var_name}.content }} to access file content]"
411
+
412
+ # For multiple files or directory mapping
413
+ var_name = self._var_alias or "file_list"
219
414
  if len(self) == 1:
220
- return f"FileInfoList(['{self[0].path}'])"
221
- return f"FileInfoList({[f.path for f in self]})"
415
+ return f"[Directory file '{self[0].path}' - Use {{ {var_name}[0].content }} or {{ {var_name}|single.content }} to access content]"
416
+ else:
417
+ paths_preview = [f.path for f in self[:2]]
418
+ if len(self) > 2:
419
+ paths_preview.append(f"... +{len(self) - 2} more")
420
+ return f"[{len(self)} files: {', '.join(paths_preview)} - Use {{ {var_name}[0].content }} or loop over files]"
222
421
 
223
422
  def __repr__(self) -> str:
224
423
  """Get detailed string representation of the file list.