ostruct-cli 0.8.8__py3-none-any.whl → 1.0.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 +3 -15
- ostruct/cli/attachment_processor.py +455 -0
- ostruct/cli/attachment_template_bridge.py +973 -0
- ostruct/cli/cli.py +187 -33
- ostruct/cli/click_options.py +775 -692
- ostruct/cli/code_interpreter.py +195 -12
- ostruct/cli/commands/__init__.py +0 -3
- ostruct/cli/commands/run.py +289 -62
- ostruct/cli/config.py +23 -22
- ostruct/cli/constants.py +89 -0
- ostruct/cli/errors.py +191 -6
- ostruct/cli/explicit_file_processor.py +0 -15
- ostruct/cli/file_info.py +118 -14
- ostruct/cli/file_list.py +82 -1
- ostruct/cli/file_search.py +68 -2
- ostruct/cli/help_json.py +235 -0
- ostruct/cli/mcp_integration.py +13 -16
- ostruct/cli/params.py +217 -0
- ostruct/cli/plan_assembly.py +335 -0
- ostruct/cli/plan_printing.py +385 -0
- ostruct/cli/progress_reporting.py +8 -56
- ostruct/cli/quick_ref_help.py +128 -0
- ostruct/cli/rich_config.py +299 -0
- ostruct/cli/runner.py +397 -190
- ostruct/cli/security/__init__.py +2 -0
- ostruct/cli/security/allowed_checker.py +41 -0
- ostruct/cli/security/normalization.py +13 -9
- ostruct/cli/security/security_manager.py +558 -17
- ostruct/cli/security/types.py +15 -0
- ostruct/cli/template_debug.py +283 -261
- ostruct/cli/template_debug_help.py +233 -142
- ostruct/cli/template_env.py +46 -5
- ostruct/cli/template_filters.py +415 -8
- ostruct/cli/template_processor.py +240 -619
- ostruct/cli/template_rendering.py +49 -73
- ostruct/cli/template_validation.py +2 -1
- ostruct/cli/token_validation.py +35 -15
- ostruct/cli/types.py +15 -19
- ostruct/cli/unicode_compat.py +283 -0
- ostruct/cli/upload_manager.py +448 -0
- ostruct/cli/utils.py +30 -0
- ostruct/cli/validators.py +272 -54
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +292 -126
- ostruct_cli-1.0.0.dist-info/RECORD +80 -0
- ostruct/cli/commands/quick_ref.py +0 -54
- ostruct/cli/template_optimizer.py +0 -478
- ostruct_cli-0.8.8.dist-info/RECORD +0 -71
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/__init__.py
CHANGED
@@ -1,25 +1,13 @@
|
|
1
1
|
"""Command-line interface for making structured OpenAI API calls."""
|
2
2
|
|
3
3
|
# Import modules for test mocking support
|
4
|
-
from . import
|
5
|
-
|
6
|
-
mcp_integration,
|
7
|
-
model_validation,
|
8
|
-
runner,
|
9
|
-
)
|
10
|
-
from .cli import (
|
11
|
-
ExitCode,
|
12
|
-
create_cli,
|
13
|
-
main,
|
14
|
-
)
|
4
|
+
from . import config, mcp_integration, model_validation, runner
|
5
|
+
from .cli import ExitCode, create_cli, main
|
15
6
|
from .path_utils import validate_path_mapping
|
16
7
|
from .registry_updates import get_update_notification
|
17
8
|
from .runner import OstructRunner
|
18
9
|
from .template_processor import validate_task_template
|
19
|
-
from .validators import
|
20
|
-
validate_schema_file,
|
21
|
-
validate_variable_mapping,
|
22
|
-
)
|
10
|
+
from .validators import validate_schema_file, validate_variable_mapping
|
23
11
|
|
24
12
|
__all__ = [
|
25
13
|
"ExitCode",
|
@@ -0,0 +1,455 @@
|
|
1
|
+
"""Attachment processing system for the new CLI interface.
|
2
|
+
|
3
|
+
This module processes the new target/alias attachment system and converts it to
|
4
|
+
the existing file routing structure for backward compatibility.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
from dataclasses import dataclass, field
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Any, Dict, List, Optional, Set, Union
|
11
|
+
|
12
|
+
from .explicit_file_processor import ExplicitRouting, ProcessingResult
|
13
|
+
from .security import SecurityManager
|
14
|
+
from .types import CLIParams
|
15
|
+
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
|
19
|
+
@dataclass
|
20
|
+
class AttachmentSpec:
|
21
|
+
"""Specification for a single attachment with target and alias."""
|
22
|
+
|
23
|
+
alias: str # User-provided alias for the attachment
|
24
|
+
path: Union[str, Path] # File or directory path
|
25
|
+
targets: Set[
|
26
|
+
str
|
27
|
+
] # Target tools: {"prompt", "code-interpreter", "file-search"}
|
28
|
+
recursive: bool = False # For directory attachments
|
29
|
+
pattern: Optional[str] = None # Glob pattern for directory attachments
|
30
|
+
from_collection: bool = (
|
31
|
+
False # Whether this came from a --collect filelist
|
32
|
+
)
|
33
|
+
collection_base_alias: Optional[str] = (
|
34
|
+
None # Original collection alias for TSES
|
35
|
+
)
|
36
|
+
attachment_type: str = (
|
37
|
+
"file" # Original attachment type: "file", "dir", or "collection"
|
38
|
+
)
|
39
|
+
|
40
|
+
|
41
|
+
@dataclass
|
42
|
+
class ProcessedAttachments:
|
43
|
+
"""Processed attachments organized by target tool."""
|
44
|
+
|
45
|
+
# Files/dirs for template access (prompt target)
|
46
|
+
template_files: List[AttachmentSpec] = field(default_factory=list)
|
47
|
+
template_dirs: List[AttachmentSpec] = field(default_factory=list)
|
48
|
+
|
49
|
+
# Files/dirs for code interpreter uploads
|
50
|
+
ci_files: List[AttachmentSpec] = field(default_factory=list)
|
51
|
+
ci_dirs: List[AttachmentSpec] = field(default_factory=list)
|
52
|
+
|
53
|
+
# Files/dirs for file search uploads
|
54
|
+
fs_files: List[AttachmentSpec] = field(default_factory=list)
|
55
|
+
fs_dirs: List[AttachmentSpec] = field(default_factory=list)
|
56
|
+
|
57
|
+
# Mapping of aliases to their specs for template context
|
58
|
+
alias_map: Dict[str, AttachmentSpec] = field(default_factory=dict)
|
59
|
+
|
60
|
+
|
61
|
+
class AttachmentProcessor:
|
62
|
+
"""Processes new attachment specifications into existing file routing."""
|
63
|
+
|
64
|
+
def __init__(self, security_manager: SecurityManager):
|
65
|
+
"""Initialize the attachment processor.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
security_manager: Security manager for file validation
|
69
|
+
"""
|
70
|
+
self.security_manager = security_manager
|
71
|
+
|
72
|
+
def process_attachments(
|
73
|
+
self, attachments: List[Dict[str, Any]]
|
74
|
+
) -> ProcessedAttachments:
|
75
|
+
"""Process attachment specifications into organized structure.
|
76
|
+
|
77
|
+
Args:
|
78
|
+
attachments: List of attachment dictionaries from CLI parsing
|
79
|
+
|
80
|
+
Returns:
|
81
|
+
ProcessedAttachments with files organized by target tool
|
82
|
+
|
83
|
+
Raises:
|
84
|
+
ValueError: If attachment specifications are invalid
|
85
|
+
"""
|
86
|
+
logger.debug("Processing %d attachments", len(attachments))
|
87
|
+
processed = ProcessedAttachments()
|
88
|
+
|
89
|
+
for attachment_dict in attachments:
|
90
|
+
# Handle filelist syntax: path can be ("@", "filelist.txt") tuple
|
91
|
+
path_value = attachment_dict["path"]
|
92
|
+
if (
|
93
|
+
isinstance(path_value, tuple)
|
94
|
+
and len(path_value) == 2
|
95
|
+
and path_value[0] == "@"
|
96
|
+
):
|
97
|
+
# Process filelist collection
|
98
|
+
filelist_specs = self._process_filelist(
|
99
|
+
path_value[1], attachment_dict
|
100
|
+
)
|
101
|
+
for spec in filelist_specs:
|
102
|
+
processed.alias_map[spec.alias] = spec
|
103
|
+
self._route_attachment(spec, processed)
|
104
|
+
else:
|
105
|
+
# Regular file/directory attachment
|
106
|
+
spec = AttachmentSpec(
|
107
|
+
alias=attachment_dict["alias"],
|
108
|
+
path=Path(path_value),
|
109
|
+
targets=set(attachment_dict["targets"]),
|
110
|
+
recursive=attachment_dict.get("recursive", False),
|
111
|
+
pattern=attachment_dict.get("pattern"),
|
112
|
+
attachment_type=attachment_dict.get(
|
113
|
+
"attachment_type", "file"
|
114
|
+
),
|
115
|
+
)
|
116
|
+
|
117
|
+
# Validate file/directory with security manager
|
118
|
+
validated_path = self.security_manager.validate_file_access(
|
119
|
+
spec.path, context=f"attachment {spec.alias}"
|
120
|
+
)
|
121
|
+
spec.path = validated_path
|
122
|
+
|
123
|
+
# Add to alias map
|
124
|
+
processed.alias_map[spec.alias] = spec
|
125
|
+
|
126
|
+
# Route to appropriate target collections
|
127
|
+
self._route_attachment(spec, processed)
|
128
|
+
|
129
|
+
logger.debug(
|
130
|
+
"Processed attachments: %d template files, %d template dirs, "
|
131
|
+
"%d CI files, %d CI dirs, %d FS files, %d FS dirs",
|
132
|
+
len(processed.template_files),
|
133
|
+
len(processed.template_dirs),
|
134
|
+
len(processed.ci_files),
|
135
|
+
len(processed.ci_dirs),
|
136
|
+
len(processed.fs_files),
|
137
|
+
len(processed.fs_dirs),
|
138
|
+
)
|
139
|
+
|
140
|
+
return processed
|
141
|
+
|
142
|
+
def _route_attachment(
|
143
|
+
self, spec: AttachmentSpec, processed: ProcessedAttachments
|
144
|
+
) -> None:
|
145
|
+
"""Route a single attachment to appropriate target collections.
|
146
|
+
|
147
|
+
Args:
|
148
|
+
spec: Attachment specification to route
|
149
|
+
processed: ProcessedAttachments to update
|
150
|
+
"""
|
151
|
+
is_dir = Path(spec.path).is_dir()
|
152
|
+
|
153
|
+
for target in spec.targets:
|
154
|
+
if target == "prompt":
|
155
|
+
if is_dir:
|
156
|
+
processed.template_dirs.append(spec)
|
157
|
+
else:
|
158
|
+
processed.template_files.append(spec)
|
159
|
+
elif target == "code-interpreter":
|
160
|
+
if is_dir:
|
161
|
+
processed.ci_dirs.append(spec)
|
162
|
+
else:
|
163
|
+
processed.ci_files.append(spec)
|
164
|
+
elif target == "file-search":
|
165
|
+
if is_dir:
|
166
|
+
processed.fs_dirs.append(spec)
|
167
|
+
else:
|
168
|
+
processed.fs_files.append(spec)
|
169
|
+
else:
|
170
|
+
logger.warning(
|
171
|
+
"Unknown target '%s' for attachment %s", target, spec.alias
|
172
|
+
)
|
173
|
+
|
174
|
+
def _process_filelist(
|
175
|
+
self, filelist_path: str, attachment_dict: Dict[str, Any]
|
176
|
+
) -> List[AttachmentSpec]:
|
177
|
+
"""Process @filelist.txt syntax for file collections.
|
178
|
+
|
179
|
+
Args:
|
180
|
+
filelist_path: Path to the filelist file
|
181
|
+
attachment_dict: Original attachment specification
|
182
|
+
|
183
|
+
Returns:
|
184
|
+
List of AttachmentSpec objects for each file in the list
|
185
|
+
|
186
|
+
Raises:
|
187
|
+
ValueError: If filelist file is invalid or files cannot be processed
|
188
|
+
"""
|
189
|
+
logger.debug("Processing filelist: %s", filelist_path)
|
190
|
+
|
191
|
+
# Validate filelist file itself
|
192
|
+
list_file = Path(filelist_path)
|
193
|
+
validated_list_file = self.security_manager.validate_file_access(
|
194
|
+
list_file, context=f"filelist for {attachment_dict['alias']}"
|
195
|
+
)
|
196
|
+
|
197
|
+
specs = []
|
198
|
+
base_alias = attachment_dict["alias"]
|
199
|
+
targets = set(attachment_dict["targets"])
|
200
|
+
|
201
|
+
try:
|
202
|
+
with open(validated_list_file, "r", encoding="utf-8") as f:
|
203
|
+
for line_num, line in enumerate(f, 1):
|
204
|
+
line = line.strip()
|
205
|
+
|
206
|
+
# Skip empty lines and comments
|
207
|
+
if not line or line.startswith("#"):
|
208
|
+
continue
|
209
|
+
|
210
|
+
try:
|
211
|
+
# Resolve relative paths relative to filelist directory
|
212
|
+
file_path = Path(line)
|
213
|
+
if not file_path.is_absolute():
|
214
|
+
file_path = validated_list_file.parent / file_path
|
215
|
+
|
216
|
+
# Validate each file through security manager
|
217
|
+
validated_file = (
|
218
|
+
self.security_manager.validate_file_access(
|
219
|
+
file_path,
|
220
|
+
context=f"filelist {filelist_path}:{line_num}",
|
221
|
+
)
|
222
|
+
)
|
223
|
+
|
224
|
+
# Create unique alias for each file in collection
|
225
|
+
# Use base_alias with file index or filename
|
226
|
+
file_alias = f"{base_alias}_{line_num}"
|
227
|
+
|
228
|
+
# Create AttachmentSpec for each file
|
229
|
+
spec = AttachmentSpec(
|
230
|
+
alias=file_alias,
|
231
|
+
path=validated_file,
|
232
|
+
targets=targets,
|
233
|
+
recursive=False, # Files from list are individual files
|
234
|
+
pattern=None,
|
235
|
+
from_collection=True, # Mark as from collection
|
236
|
+
collection_base_alias=base_alias, # Store original alias
|
237
|
+
attachment_type="collection", # From --collect
|
238
|
+
)
|
239
|
+
|
240
|
+
specs.append(spec)
|
241
|
+
logger.debug(
|
242
|
+
"Added file from filelist: %s -> %s",
|
243
|
+
validated_file,
|
244
|
+
file_alias,
|
245
|
+
)
|
246
|
+
|
247
|
+
except Exception as e:
|
248
|
+
logger.warning(
|
249
|
+
"Filelist %s:%d: Failed to process '%s': %s",
|
250
|
+
filelist_path,
|
251
|
+
line_num,
|
252
|
+
line,
|
253
|
+
e,
|
254
|
+
)
|
255
|
+
# Continue processing other files in permissive modes
|
256
|
+
if hasattr(self.security_manager, "security_mode"):
|
257
|
+
from .security.types import PathSecurity
|
258
|
+
|
259
|
+
if (
|
260
|
+
getattr(
|
261
|
+
self.security_manager,
|
262
|
+
"security_mode",
|
263
|
+
None,
|
264
|
+
)
|
265
|
+
== PathSecurity.STRICT
|
266
|
+
):
|
267
|
+
raise ValueError(
|
268
|
+
f"Filelist processing failed at line {line_num}: {e}"
|
269
|
+
)
|
270
|
+
|
271
|
+
except IOError as e:
|
272
|
+
logger.error("Failed to read filelist %s: %s", filelist_path, e)
|
273
|
+
raise ValueError(f"Cannot read filelist {filelist_path}: {e}")
|
274
|
+
|
275
|
+
logger.debug(
|
276
|
+
"Processed filelist %s: %d files loaded", filelist_path, len(specs)
|
277
|
+
)
|
278
|
+
|
279
|
+
return specs
|
280
|
+
|
281
|
+
def convert_to_explicit_routing(
|
282
|
+
self, processed: ProcessedAttachments
|
283
|
+
) -> ExplicitRouting:
|
284
|
+
"""Convert ProcessedAttachments to legacy ExplicitRouting format.
|
285
|
+
|
286
|
+
Args:
|
287
|
+
processed: ProcessedAttachments to convert
|
288
|
+
|
289
|
+
Returns:
|
290
|
+
ExplicitRouting compatible with existing file processor
|
291
|
+
"""
|
292
|
+
routing = ExplicitRouting()
|
293
|
+
|
294
|
+
# Convert template attachments
|
295
|
+
routing.template_files = [
|
296
|
+
str(spec.path) for spec in processed.template_files
|
297
|
+
]
|
298
|
+
routing.template_dirs = [
|
299
|
+
str(spec.path) for spec in processed.template_dirs
|
300
|
+
]
|
301
|
+
routing.template_dir_aliases = [
|
302
|
+
(spec.alias, str(spec.path)) for spec in processed.template_dirs
|
303
|
+
]
|
304
|
+
|
305
|
+
# Convert code interpreter attachments
|
306
|
+
routing.code_interpreter_files = [
|
307
|
+
str(spec.path) for spec in processed.ci_files
|
308
|
+
]
|
309
|
+
routing.code_interpreter_dirs = [
|
310
|
+
str(spec.path) for spec in processed.ci_dirs
|
311
|
+
]
|
312
|
+
routing.code_interpreter_dir_aliases = [
|
313
|
+
(spec.alias, str(spec.path)) for spec in processed.ci_dirs
|
314
|
+
]
|
315
|
+
|
316
|
+
# Convert file search attachments
|
317
|
+
routing.file_search_files = [
|
318
|
+
str(spec.path) for spec in processed.fs_files
|
319
|
+
]
|
320
|
+
routing.file_search_dirs = [
|
321
|
+
str(spec.path) for spec in processed.fs_dirs
|
322
|
+
]
|
323
|
+
routing.file_search_dir_aliases = [
|
324
|
+
(spec.alias, str(spec.path)) for spec in processed.fs_dirs
|
325
|
+
]
|
326
|
+
|
327
|
+
return routing
|
328
|
+
|
329
|
+
def create_processing_result(
|
330
|
+
self, processed: ProcessedAttachments
|
331
|
+
) -> ProcessingResult:
|
332
|
+
"""Create ProcessingResult from ProcessedAttachments.
|
333
|
+
|
334
|
+
Args:
|
335
|
+
processed: ProcessedAttachments to convert
|
336
|
+
|
337
|
+
Returns:
|
338
|
+
ProcessingResult compatible with existing pipeline
|
339
|
+
"""
|
340
|
+
routing = self.convert_to_explicit_routing(processed)
|
341
|
+
|
342
|
+
# Determine enabled tools
|
343
|
+
enabled_tools = set()
|
344
|
+
if processed.template_files or processed.template_dirs:
|
345
|
+
enabled_tools.add("template")
|
346
|
+
if processed.ci_files or processed.ci_dirs:
|
347
|
+
enabled_tools.add("code-interpreter")
|
348
|
+
if processed.fs_files or processed.fs_dirs:
|
349
|
+
enabled_tools.add("file-search")
|
350
|
+
|
351
|
+
# Create validated files mapping
|
352
|
+
validated_files = {
|
353
|
+
"template": routing.template_files + routing.template_dirs,
|
354
|
+
"code-interpreter": routing.code_interpreter_files
|
355
|
+
+ routing.code_interpreter_dirs,
|
356
|
+
"file-search": routing.file_search_files
|
357
|
+
+ routing.file_search_dirs,
|
358
|
+
}
|
359
|
+
|
360
|
+
return ProcessingResult(
|
361
|
+
routing=routing,
|
362
|
+
enabled_tools=enabled_tools,
|
363
|
+
validated_files=validated_files,
|
364
|
+
auto_enabled_feedback=None, # New system doesn't auto-enable
|
365
|
+
)
|
366
|
+
|
367
|
+
|
368
|
+
def process_new_attachments(
|
369
|
+
args: CLIParams, security_manager: SecurityManager
|
370
|
+
) -> Optional[ProcessingResult]:
|
371
|
+
"""Process new-style attachment arguments into file routing.
|
372
|
+
|
373
|
+
This function checks for new attachment syntax in CLI args and processes
|
374
|
+
them into the existing file routing system for backward compatibility.
|
375
|
+
|
376
|
+
Args:
|
377
|
+
args: CLI parameters (may contain new attachment syntax)
|
378
|
+
security_manager: Security manager for file validation
|
379
|
+
|
380
|
+
Returns:
|
381
|
+
ProcessingResult if new attachments found, None otherwise
|
382
|
+
"""
|
383
|
+
# Check if args contain new attachment syntax
|
384
|
+
if not _has_new_attachment_syntax(args):
|
385
|
+
return None
|
386
|
+
|
387
|
+
logger.debug("Detected new attachment syntax, processing...")
|
388
|
+
processor = AttachmentProcessor(security_manager)
|
389
|
+
|
390
|
+
# Extract attachment specifications from args
|
391
|
+
attachments = _extract_attachments_from_args(args)
|
392
|
+
|
393
|
+
# Process attachments
|
394
|
+
processed = processor.process_attachments(attachments)
|
395
|
+
|
396
|
+
# Convert to processing result
|
397
|
+
result = processor.create_processing_result(processed)
|
398
|
+
|
399
|
+
logger.debug("New attachment processing complete")
|
400
|
+
return result
|
401
|
+
|
402
|
+
|
403
|
+
def _has_new_attachment_syntax(args: CLIParams) -> bool:
|
404
|
+
"""Check if CLI args contain new attachment syntax.
|
405
|
+
|
406
|
+
Args:
|
407
|
+
args: CLI parameters to check
|
408
|
+
|
409
|
+
Returns:
|
410
|
+
True if new attachment syntax is present
|
411
|
+
"""
|
412
|
+
# Check for new attachment-related keys
|
413
|
+
new_syntax_keys = ["attaches", "dirs", "collects"]
|
414
|
+
return any(args.get(key) for key in new_syntax_keys)
|
415
|
+
|
416
|
+
|
417
|
+
def _extract_attachments_from_args(args: CLIParams) -> List[Dict[str, Any]]:
|
418
|
+
"""Extract attachment specifications from CLI args.
|
419
|
+
|
420
|
+
Args:
|
421
|
+
args: CLI parameters containing attachment specifications
|
422
|
+
|
423
|
+
Returns:
|
424
|
+
List of attachment dictionaries with attachment_type added
|
425
|
+
"""
|
426
|
+
attachments: List[Dict[str, Any]] = []
|
427
|
+
|
428
|
+
# Extract --attach specifications (file attachments)
|
429
|
+
attaches = args.get("attaches", [])
|
430
|
+
if attaches:
|
431
|
+
for attach in attaches:
|
432
|
+
attach_with_type = dict(attach)
|
433
|
+
attach_with_type["attachment_type"] = "file"
|
434
|
+
attachments.append(attach_with_type)
|
435
|
+
|
436
|
+
# Extract --dir specifications (directory attachments)
|
437
|
+
dirs = args.get("dirs", [])
|
438
|
+
if dirs:
|
439
|
+
for dir_spec in dirs:
|
440
|
+
dir_with_type = dict(dir_spec)
|
441
|
+
dir_with_type["attachment_type"] = "dir"
|
442
|
+
attachments.append(dir_with_type)
|
443
|
+
|
444
|
+
# Extract --collect specifications (collection attachments)
|
445
|
+
collects = args.get("collects", [])
|
446
|
+
if collects:
|
447
|
+
for collect in collects:
|
448
|
+
collect_with_type = dict(collect)
|
449
|
+
collect_with_type["attachment_type"] = "collection"
|
450
|
+
attachments.append(collect_with_type)
|
451
|
+
|
452
|
+
logger.debug(
|
453
|
+
"Extracted %d attachment specifications from args", len(attachments)
|
454
|
+
)
|
455
|
+
return attachments
|