griptape-nodes 0.64.10__py3-none-any.whl → 0.65.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.
- griptape_nodes/app/app.py +25 -5
- griptape_nodes/cli/commands/init.py +65 -54
- griptape_nodes/cli/commands/libraries.py +92 -85
- griptape_nodes/cli/commands/self.py +121 -0
- griptape_nodes/common/node_executor.py +2142 -101
- griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
- griptape_nodes/exe_types/connections.py +114 -19
- griptape_nodes/exe_types/core_types.py +225 -7
- griptape_nodes/exe_types/flow.py +3 -3
- griptape_nodes/exe_types/node_types.py +681 -225
- griptape_nodes/exe_types/param_components/README.md +414 -0
- griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
- griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
- griptape_nodes/machines/control_flow.py +77 -38
- griptape_nodes/machines/dag_builder.py +148 -70
- griptape_nodes/machines/parallel_resolution.py +61 -35
- griptape_nodes/machines/sequential_resolution.py +11 -113
- griptape_nodes/retained_mode/events/app_events.py +1 -0
- griptape_nodes/retained_mode/events/base_events.py +16 -13
- griptape_nodes/retained_mode/events/connection_events.py +3 -0
- griptape_nodes/retained_mode/events/execution_events.py +35 -0
- griptape_nodes/retained_mode/events/flow_events.py +15 -2
- griptape_nodes/retained_mode/events/library_events.py +347 -0
- griptape_nodes/retained_mode/events/node_events.py +48 -0
- griptape_nodes/retained_mode/events/os_events.py +86 -3
- griptape_nodes/retained_mode/events/project_events.py +15 -1
- griptape_nodes/retained_mode/events/workflow_events.py +48 -1
- griptape_nodes/retained_mode/griptape_nodes.py +6 -2
- griptape_nodes/retained_mode/managers/config_manager.py +10 -8
- griptape_nodes/retained_mode/managers/event_manager.py +168 -0
- griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
- griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
- griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
- griptape_nodes/retained_mode/managers/library_manager.py +1143 -139
- griptape_nodes/retained_mode/managers/model_manager.py +2 -3
- griptape_nodes/retained_mode/managers/node_manager.py +148 -25
- griptape_nodes/retained_mode/managers/object_manager.py +3 -1
- griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
- griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
- griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
- griptape_nodes/retained_mode/managers/settings.py +21 -1
- griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
- griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
- griptape_nodes/retained_mode/retained_mode.py +3 -3
- griptape_nodes/traits/button.py +44 -2
- griptape_nodes/traits/file_system_picker.py +2 -2
- griptape_nodes/utils/file_utils.py +101 -0
- griptape_nodes/utils/git_utils.py +1226 -0
- griptape_nodes/utils/library_utils.py +122 -0
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/entry_points.txt +0 -0
|
@@ -2,6 +2,7 @@ import base64
|
|
|
2
2
|
import logging
|
|
3
3
|
import mimetypes
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
import shutil
|
|
6
7
|
import stat
|
|
7
8
|
import subprocess
|
|
@@ -11,9 +12,15 @@ from pathlib import Path
|
|
|
11
12
|
from typing import Any, NamedTuple
|
|
12
13
|
|
|
13
14
|
import aioshutil
|
|
15
|
+
import portalocker
|
|
14
16
|
from binaryornot.check import is_binary
|
|
15
17
|
from rich.console import Console
|
|
16
18
|
|
|
19
|
+
from griptape_nodes.common.macro_parser import MacroResolutionError, MacroResolutionFailure, ParsedMacro
|
|
20
|
+
from griptape_nodes.common.macro_parser.exceptions import MacroResolutionFailureReason
|
|
21
|
+
from griptape_nodes.common.macro_parser.formats import NumericPaddingFormat
|
|
22
|
+
from griptape_nodes.common.macro_parser.resolution import partial_resolve
|
|
23
|
+
from griptape_nodes.common.macro_parser.segments import ParsedStaticValue, ParsedVariable
|
|
17
24
|
from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
|
|
18
25
|
from griptape_nodes.retained_mode.events.base_events import ResultDetails, ResultPayload
|
|
19
26
|
from griptape_nodes.retained_mode.events.os_events import (
|
|
@@ -35,6 +42,9 @@ from griptape_nodes.retained_mode.events.os_events import (
|
|
|
35
42
|
GetFileInfoRequest,
|
|
36
43
|
GetFileInfoResultFailure,
|
|
37
44
|
GetFileInfoResultSuccess,
|
|
45
|
+
GetNextUnusedFilenameRequest,
|
|
46
|
+
GetNextUnusedFilenameResultFailure,
|
|
47
|
+
GetNextUnusedFilenameResultSuccess,
|
|
38
48
|
ListDirectoryRequest,
|
|
39
49
|
ListDirectoryResultFailure,
|
|
40
50
|
ListDirectoryResultSuccess,
|
|
@@ -51,6 +61,7 @@ from griptape_nodes.retained_mode.events.os_events import (
|
|
|
51
61
|
WriteFileResultFailure,
|
|
52
62
|
WriteFileResultSuccess,
|
|
53
63
|
)
|
|
64
|
+
from griptape_nodes.retained_mode.events.project_events import MacroPath
|
|
54
65
|
from griptape_nodes.retained_mode.events.resource_events import (
|
|
55
66
|
CreateResourceInstanceRequest,
|
|
56
67
|
CreateResourceInstanceResultSuccess,
|
|
@@ -67,6 +78,9 @@ console = Console()
|
|
|
67
78
|
# Windows MAX_PATH limit - paths longer than this need \\?\ prefix
|
|
68
79
|
WINDOWS_MAX_PATH = 260
|
|
69
80
|
|
|
81
|
+
# Maximum number of indexed candidates to try when CREATE_NEW policy is used
|
|
82
|
+
MAX_INDEXED_CANDIDATES = 1000
|
|
83
|
+
|
|
70
84
|
|
|
71
85
|
@dataclass
|
|
72
86
|
class DiskSpaceInfo:
|
|
@@ -87,6 +101,20 @@ class FileContentResult(NamedTuple):
|
|
|
87
101
|
file_size: int
|
|
88
102
|
|
|
89
103
|
|
|
104
|
+
class FileWriteAttemptResult(NamedTuple):
|
|
105
|
+
"""Result of attempting to write a file.
|
|
106
|
+
|
|
107
|
+
Possible outcomes:
|
|
108
|
+
- Success: bytes_written is set, failure_reason and error_message are None
|
|
109
|
+
- Continue: all fields are None (file exists/locked but caller wants to continue)
|
|
110
|
+
- Failure: failure_reason and error_message are set, bytes_written is None
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
bytes_written: int | None
|
|
114
|
+
failure_reason: FileIOFailureReason | None
|
|
115
|
+
error_message: str | None
|
|
116
|
+
|
|
117
|
+
|
|
90
118
|
@dataclass
|
|
91
119
|
class CopyTreeValidationResult:
|
|
92
120
|
"""Result from validating copy tree paths."""
|
|
@@ -97,6 +125,43 @@ class CopyTreeValidationResult:
|
|
|
97
125
|
destination_path: Path
|
|
98
126
|
|
|
99
127
|
|
|
128
|
+
class FilenameParts(NamedTuple):
|
|
129
|
+
"""Components of a filename for suffix injection strategy.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
directory: Parent directory path
|
|
133
|
+
basename: Filename without extension or suffix
|
|
134
|
+
extension: File extension including dot (e.g., ".png"), empty string if no extension
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
directory: Path
|
|
138
|
+
basename: str
|
|
139
|
+
extension: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class FilePathValidationError(Exception):
|
|
143
|
+
"""Raised when file path validation fails before write operation.
|
|
144
|
+
|
|
145
|
+
This exception is raised by validation methods when a file path
|
|
146
|
+
is unsuitable for writing due to policy violations, missing parent
|
|
147
|
+
directories, or invalid path types.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
message: str,
|
|
153
|
+
reason: FileIOFailureReason,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Initialize FilePathValidationError.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
message: Human-readable error message
|
|
159
|
+
reason: Classification of why validation failed
|
|
160
|
+
"""
|
|
161
|
+
super().__init__(message)
|
|
162
|
+
self.reason = reason
|
|
163
|
+
|
|
164
|
+
|
|
100
165
|
@dataclass
|
|
101
166
|
class CopyTreeStats:
|
|
102
167
|
"""Statistics from copying a directory tree."""
|
|
@@ -239,6 +304,115 @@ class OSManager:
|
|
|
239
304
|
# Re-raise the exception for non-workspace mode
|
|
240
305
|
raise
|
|
241
306
|
|
|
307
|
+
def _resolve_macro_path_to_string(self, macro_path: MacroPath) -> str | MacroResolutionFailure:
|
|
308
|
+
"""Resolve MacroPath to string, handling missing variables.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
macro_path: MacroPath containing parsed macro and variables
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
str: Successfully resolved path string
|
|
315
|
+
MacroResolutionFailure: Details about resolution failure (missing variables, etc.)
|
|
316
|
+
|
|
317
|
+
Examples:
|
|
318
|
+
# Success case
|
|
319
|
+
macro_path = MacroPath(ParsedMacro("{outputs}/file.png"), {"outputs": "/path"})
|
|
320
|
+
result = self._resolve_macro_path_to_string(macro_path)
|
|
321
|
+
# Returns: "/path/file.png"
|
|
322
|
+
|
|
323
|
+
# Missing variable case
|
|
324
|
+
macro_path = MacroPath(ParsedMacro("{outputs}/{frame}.png"), {"outputs": "/path"})
|
|
325
|
+
result = self._resolve_macro_path_to_string(macro_path)
|
|
326
|
+
# Returns: MacroResolutionFailure(missing_variables={"frame"}, ...)
|
|
327
|
+
"""
|
|
328
|
+
secrets_manager = GriptapeNodes.SecretsManager()
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
return macro_path.parsed_macro.resolve(macro_path.variables, secrets_manager)
|
|
332
|
+
except MacroResolutionError as e:
|
|
333
|
+
return MacroResolutionFailure(
|
|
334
|
+
failure_reason=e.failure_reason or MacroResolutionFailureReason.MISSING_REQUIRED_VARIABLES,
|
|
335
|
+
variable_name=e.variable_name,
|
|
336
|
+
missing_variables=e.missing_variables,
|
|
337
|
+
error_details=str(e),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _validate_file_path_for_write(
|
|
341
|
+
self,
|
|
342
|
+
file_path: Path,
|
|
343
|
+
*,
|
|
344
|
+
check_not_exists: bool,
|
|
345
|
+
create_parents: bool,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Validate file path is suitable for writing.
|
|
348
|
+
|
|
349
|
+
Checks:
|
|
350
|
+
- Path is not a directory
|
|
351
|
+
- File doesn't exist (only if check_not_exists=True, for FAIL policy)
|
|
352
|
+
- Parent directory exists OR create_parents=True
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
file_path: Path to validate
|
|
356
|
+
check_not_exists: If True, fail if file already exists (FAIL policy)
|
|
357
|
+
create_parents: If True, parent creation allowed (policy check only)
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
FilePathValidationError: If validation fails, contains reason and message
|
|
361
|
+
|
|
362
|
+
Examples:
|
|
363
|
+
# FAIL policy: check file doesn't exist
|
|
364
|
+
try:
|
|
365
|
+
self._validate_file_path_for_write(path, check_not_exists=True, create_parents=True)
|
|
366
|
+
except FilePathValidationError as e:
|
|
367
|
+
# Handle validation failure: e.reason, str(e)
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
# OVERWRITE policy: existence OK
|
|
371
|
+
self._validate_file_path_for_write(path, check_not_exists=False, create_parents=False)
|
|
372
|
+
"""
|
|
373
|
+
normalized_path = self.normalize_path_for_platform(file_path)
|
|
374
|
+
|
|
375
|
+
# Check if path is a directory
|
|
376
|
+
try:
|
|
377
|
+
if Path(normalized_path).is_dir():
|
|
378
|
+
raise FilePathValidationError(
|
|
379
|
+
message=f"Path is a directory, not a file: {file_path}",
|
|
380
|
+
reason=FileIOFailureReason.IS_DIRECTORY,
|
|
381
|
+
)
|
|
382
|
+
except OSError as e:
|
|
383
|
+
raise FilePathValidationError(
|
|
384
|
+
message=f"Error checking if path is directory {file_path}: {e}",
|
|
385
|
+
reason=FileIOFailureReason.IO_ERROR,
|
|
386
|
+
) from e
|
|
387
|
+
|
|
388
|
+
# Check if file exists (FAIL policy only)
|
|
389
|
+
if check_not_exists:
|
|
390
|
+
try:
|
|
391
|
+
if Path(normalized_path).exists():
|
|
392
|
+
raise FilePathValidationError(
|
|
393
|
+
message=f"File exists and existing_file_policy is FAIL: {file_path}",
|
|
394
|
+
reason=FileIOFailureReason.POLICY_NO_OVERWRITE,
|
|
395
|
+
)
|
|
396
|
+
except OSError as e:
|
|
397
|
+
raise FilePathValidationError(
|
|
398
|
+
message=f"Error checking if file exists {file_path}: {e}",
|
|
399
|
+
reason=FileIOFailureReason.IO_ERROR,
|
|
400
|
+
) from e
|
|
401
|
+
|
|
402
|
+
# Check parent directory exists or can be created
|
|
403
|
+
parent_normalized = self.normalize_path_for_platform(file_path.parent)
|
|
404
|
+
try:
|
|
405
|
+
if not Path(parent_normalized).exists() and not create_parents:
|
|
406
|
+
raise FilePathValidationError(
|
|
407
|
+
message=f"Parent directory does not exist and create_parents is False: {file_path.parent}",
|
|
408
|
+
reason=FileIOFailureReason.POLICY_NO_CREATE_PARENT_DIRS,
|
|
409
|
+
)
|
|
410
|
+
except OSError as e:
|
|
411
|
+
raise FilePathValidationError(
|
|
412
|
+
message=f"Error checking parent directory {file_path.parent}: {e}",
|
|
413
|
+
reason=FileIOFailureReason.IO_ERROR,
|
|
414
|
+
) from e
|
|
415
|
+
|
|
242
416
|
def _validate_workspace_path(self, path: Path) -> tuple[bool, Path]:
|
|
243
417
|
"""Check if a path is within workspace and return relative path if it is.
|
|
244
418
|
|
|
@@ -271,6 +445,79 @@ class OSManager:
|
|
|
271
445
|
logger.debug(msg)
|
|
272
446
|
return True, relative
|
|
273
447
|
|
|
448
|
+
@staticmethod
|
|
449
|
+
def strip_surrounding_quotes(path_str: str) -> str:
|
|
450
|
+
"""Strip surrounding quotes only if they match (from 'Copy as Pathname').
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
path_str: The path string to process
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Path string with surrounding quotes removed if present
|
|
457
|
+
"""
|
|
458
|
+
if len(path_str) >= 2 and ( # noqa: PLR2004
|
|
459
|
+
(path_str.startswith("'") and path_str.endswith("'"))
|
|
460
|
+
or (path_str.startswith('"') and path_str.endswith('"'))
|
|
461
|
+
):
|
|
462
|
+
return path_str[1:-1]
|
|
463
|
+
return path_str
|
|
464
|
+
|
|
465
|
+
def sanitize_path_string(self, path_str: str) -> str:
|
|
466
|
+
r"""Strip surrounding quotes and shell escape characters from paths.
|
|
467
|
+
|
|
468
|
+
Handles macOS Finder's 'Copy as Pathname' format which escapes
|
|
469
|
+
spaces, apostrophes, and other special characters with backslashes.
|
|
470
|
+
Only removes backslashes before shell-special characters to avoid
|
|
471
|
+
breaking Windows paths like C:\Users\file.txt.
|
|
472
|
+
|
|
473
|
+
Examples:
|
|
474
|
+
macOS Finder paths (the reason this exists!):
|
|
475
|
+
"/Downloads/Dragon\'s\ Curse/screenshot.jpg"
|
|
476
|
+
-> "/Downloads/Dragon's Curse/screenshot.jpg"
|
|
477
|
+
|
|
478
|
+
"/Test\ Images/Level\ 1\ -\ Knight\'s\ Quest/file.png"
|
|
479
|
+
-> "/Test Images/Level 1 - Knight's Quest/file.png"
|
|
480
|
+
|
|
481
|
+
Quoted paths:
|
|
482
|
+
'"/path/with spaces/file.txt"'
|
|
483
|
+
-> "/path/with spaces/file.txt"
|
|
484
|
+
|
|
485
|
+
Windows paths (preserved correctly):
|
|
486
|
+
"C:\Users\Desktop\file.txt"
|
|
487
|
+
-> "C:\Users\Desktop\file.txt"
|
|
488
|
+
|
|
489
|
+
Windows extended-length paths:
|
|
490
|
+
r"\\?\C:\Very\ Long\ Path\file.txt"
|
|
491
|
+
-> r"\\?\C:\Very Long Path\file.txt"
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
path_str: The path string to sanitize
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Sanitized path string
|
|
498
|
+
"""
|
|
499
|
+
# First, strip surrounding quotes
|
|
500
|
+
path_str = OSManager.strip_surrounding_quotes(path_str)
|
|
501
|
+
|
|
502
|
+
# Handle Windows extended-length paths (\\?\...) specially
|
|
503
|
+
# These are used for paths longer than 260 characters on Windows
|
|
504
|
+
# We need to sanitize the path part but preserve the prefix
|
|
505
|
+
extended_length_prefix = ""
|
|
506
|
+
if path_str.startswith("\\\\?\\"):
|
|
507
|
+
extended_length_prefix = "\\\\?\\"
|
|
508
|
+
path_str = path_str[4:] # Remove prefix temporarily
|
|
509
|
+
|
|
510
|
+
# Remove shell escape characters (backslashes before special chars only)
|
|
511
|
+
# Matches: space ' " ( ) { } [ ] & | ; < > $ ` ! * ? /
|
|
512
|
+
# Does NOT match: \U \t \f etc in Windows paths like C:\Users
|
|
513
|
+
path_str = re.sub(r"\\([ '\"(){}[\]&|;<>$`!*?/])", r"\1", path_str)
|
|
514
|
+
|
|
515
|
+
# Restore extended-length prefix if it was present
|
|
516
|
+
if extended_length_prefix:
|
|
517
|
+
path_str = extended_length_prefix + path_str
|
|
518
|
+
|
|
519
|
+
return path_str
|
|
520
|
+
|
|
274
521
|
def normalize_path_for_platform(self, path: Path) -> str:
|
|
275
522
|
r"""Convert Path to string with Windows long path support if needed.
|
|
276
523
|
|
|
@@ -299,6 +546,386 @@ class OSManager:
|
|
|
299
546
|
|
|
300
547
|
return path_str
|
|
301
548
|
|
|
549
|
+
# ============================================================================
|
|
550
|
+
# CREATE_NEW File Collision Policy - Helper Methods
|
|
551
|
+
# ============================================================================
|
|
552
|
+
|
|
553
|
+
def _identify_index_variable(
|
|
554
|
+
self, parsed_macro: ParsedMacro, variables: dict[str, str | int]
|
|
555
|
+
) -> ParsedVariable | None:
|
|
556
|
+
"""Identify which variable should be used for auto-incrementing.
|
|
557
|
+
|
|
558
|
+
Analyzes the macro to find unresolved required variables. Returns None if all
|
|
559
|
+
variables are resolved (fallback to suffix injection), returns ParsedVariable
|
|
560
|
+
if exactly one unresolved variable exists, raises error if multiple unresolved.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
parsed_macro: Parsed macro template
|
|
564
|
+
variables: Variable values provided by user
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
ParsedVariable if exactly one unresolved variable exists,
|
|
568
|
+
None if all variables resolved (use suffix injection fallback)
|
|
569
|
+
|
|
570
|
+
Raises:
|
|
571
|
+
ValueError: If multiple unresolved required variables exist (ambiguous)
|
|
572
|
+
|
|
573
|
+
Examples:
|
|
574
|
+
Template: "{outputs}/frame_{frame_num:05}.png"
|
|
575
|
+
Variables: {"outputs": "/path"}
|
|
576
|
+
→ Returns ParsedVariable with name="frame_num", format_specs=[NumericPaddingFormat(5)]
|
|
577
|
+
|
|
578
|
+
Template: "{outputs}/render.png"
|
|
579
|
+
Variables: {"outputs": "/path"}
|
|
580
|
+
→ Returns None (use suffix injection)
|
|
581
|
+
|
|
582
|
+
Template: "{outputs}/{batch}/frame_{frame_num}.png"
|
|
583
|
+
Variables: {"outputs": "/path"}
|
|
584
|
+
→ Raises ValueError (batch and frame_num both unresolved)
|
|
585
|
+
"""
|
|
586
|
+
# Partially resolve to identify unresolved variables
|
|
587
|
+
secrets_manager = GriptapeNodes.SecretsManager()
|
|
588
|
+
partial = partial_resolve(parsed_macro.template, parsed_macro.segments, variables, secrets_manager)
|
|
589
|
+
|
|
590
|
+
# Get unresolved variables (optional variables already filtered out)
|
|
591
|
+
unresolved = partial.get_unresolved_variables()
|
|
592
|
+
|
|
593
|
+
if len(unresolved) == 0:
|
|
594
|
+
# All variables resolved - use suffix injection fallback
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
if len(unresolved) > 1:
|
|
598
|
+
# Multiple unresolved - ambiguous which to auto-increment
|
|
599
|
+
unresolved_names = [var.info.name for var in unresolved]
|
|
600
|
+
msg = (
|
|
601
|
+
f"CREATE_NEW policy requires at most one unresolved variable for auto-increment, "
|
|
602
|
+
f"found {len(unresolved)}: {', '.join(unresolved_names)}"
|
|
603
|
+
)
|
|
604
|
+
raise ValueError(msg)
|
|
605
|
+
|
|
606
|
+
# Exactly one unresolved variable - return it directly
|
|
607
|
+
return unresolved[0]
|
|
608
|
+
|
|
609
|
+
def _build_glob_pattern_from_partially_resolved(self, partial_segments: list, index_var_name: str) -> str:
|
|
610
|
+
"""Build glob pattern by replacing index variable with wildcards.
|
|
611
|
+
|
|
612
|
+
Takes partially resolved segments (from partial_resolve) and replaces the index
|
|
613
|
+
variable with wildcard patterns based on its format specs.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
partial_segments: Segments from PartiallyResolvedMacro.segments
|
|
617
|
+
index_var_name: Name of the variable to replace with wildcards
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
Glob pattern string with wildcards for index variable
|
|
621
|
+
|
|
622
|
+
Examples:
|
|
623
|
+
Segments for "/path/frame_{index:05}.png" with index unresolved:
|
|
624
|
+
→ "/path/frame_?????.png"
|
|
625
|
+
|
|
626
|
+
Segments for "/path/batch_{index:03}_frame_{index:05}.png":
|
|
627
|
+
→ "/path/batch_???_frame_?????.png"
|
|
628
|
+
|
|
629
|
+
Segments for "/path/frame_{index}.png" (no padding):
|
|
630
|
+
→ "/path/frame_*.png"
|
|
631
|
+
"""
|
|
632
|
+
pattern_parts = []
|
|
633
|
+
|
|
634
|
+
for segment in partial_segments:
|
|
635
|
+
if isinstance(segment, ParsedStaticValue):
|
|
636
|
+
# Keep static text as-is
|
|
637
|
+
pattern_parts.append(segment.text)
|
|
638
|
+
elif isinstance(segment, ParsedVariable):
|
|
639
|
+
if segment.info.name == index_var_name:
|
|
640
|
+
# Replace index variable with wildcards based on padding
|
|
641
|
+
has_padding = False
|
|
642
|
+
for format_spec in segment.format_specs:
|
|
643
|
+
if isinstance(format_spec, NumericPaddingFormat):
|
|
644
|
+
# Use exact number of wildcards for padding width
|
|
645
|
+
pattern_parts.append("?" * format_spec.width)
|
|
646
|
+
has_padding = True
|
|
647
|
+
break
|
|
648
|
+
|
|
649
|
+
if not has_padding:
|
|
650
|
+
# No padding format - match any number of digits
|
|
651
|
+
pattern_parts.append("*")
|
|
652
|
+
else:
|
|
653
|
+
# This shouldn't happen - all non-index variables should be resolved
|
|
654
|
+
msg = f"Unexpected unresolved variable '{segment.info.name}' when building glob pattern"
|
|
655
|
+
raise ValueError(msg)
|
|
656
|
+
|
|
657
|
+
return "".join(pattern_parts)
|
|
658
|
+
|
|
659
|
+
def _extract_index_from_filename(
|
|
660
|
+
self, filename: str, parsed_macro: ParsedMacro, index_var_name: str, variables: dict[str, str | int]
|
|
661
|
+
) -> int | None:
|
|
662
|
+
"""Extract index value from a filename by reverse-matching against macro.
|
|
663
|
+
|
|
664
|
+
Uses the macro's extract_variables() method to parse the filename and extract
|
|
665
|
+
the index variable value.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
filename: Filename to parse (e.g., "frame_00123.png")
|
|
669
|
+
parsed_macro: Original parsed macro template
|
|
670
|
+
index_var_name: Name of the index variable to extract
|
|
671
|
+
variables: Known variable values (for partial matching)
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Integer index value if successfully extracted, None if filename doesn't match
|
|
675
|
+
|
|
676
|
+
Examples:
|
|
677
|
+
Filename: "frame_00123.png"
|
|
678
|
+
Template: "{outputs}/frame_{frame_num:05}.png"
|
|
679
|
+
Variables: {"outputs": "/path"}
|
|
680
|
+
→ Returns 123
|
|
681
|
+
"""
|
|
682
|
+
secrets_manager = GriptapeNodes.SecretsManager()
|
|
683
|
+
|
|
684
|
+
# Use macro's extract_variables to reverse-match
|
|
685
|
+
extracted = parsed_macro.extract_variables(filename, variables, secrets_manager)
|
|
686
|
+
|
|
687
|
+
if extracted is None:
|
|
688
|
+
# Filename doesn't match template
|
|
689
|
+
return None
|
|
690
|
+
|
|
691
|
+
if index_var_name not in extracted:
|
|
692
|
+
# Index variable not found in extraction
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
value = extracted[index_var_name]
|
|
696
|
+
|
|
697
|
+
# Convert to int (format_spec.reverse() should have done this already)
|
|
698
|
+
if isinstance(value, int):
|
|
699
|
+
return value
|
|
700
|
+
|
|
701
|
+
# Try to parse as string
|
|
702
|
+
if isinstance(value, str) and value.isdigit():
|
|
703
|
+
return int(value)
|
|
704
|
+
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
def _parse_filename_parts(self, path: Path) -> FilenameParts:
|
|
708
|
+
"""Parse filename into directory, basename, and extension for suffix injection.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
path: Full file path to parse
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
FilenameParts with directory, basename, extension
|
|
715
|
+
|
|
716
|
+
Examples:
|
|
717
|
+
/path/to/render.png → FilenameParts(Path("/path/to"), "render", ".png")
|
|
718
|
+
/path/to/file → FilenameParts(Path("/path/to"), "file", "")
|
|
719
|
+
/path/to/.dotfile → FilenameParts(Path("/path/to"), ".dotfile", "")
|
|
720
|
+
/path/to/file.tar.gz → FilenameParts(Path("/path/to"), "file.tar", ".gz")
|
|
721
|
+
"""
|
|
722
|
+
directory = path.parent
|
|
723
|
+
filename = path.name
|
|
724
|
+
|
|
725
|
+
# Handle dotfiles (files starting with .)
|
|
726
|
+
if filename.startswith(".") and filename.count(".") == 1:
|
|
727
|
+
# .dotfile with no extension
|
|
728
|
+
return FilenameParts(directory=directory, basename=filename, extension="")
|
|
729
|
+
|
|
730
|
+
# Find last dot for extension
|
|
731
|
+
if "." in filename:
|
|
732
|
+
last_dot = filename.rfind(".")
|
|
733
|
+
basename = filename[:last_dot]
|
|
734
|
+
extension = filename[last_dot:]
|
|
735
|
+
return FilenameParts(directory=directory, basename=basename, extension=extension)
|
|
736
|
+
|
|
737
|
+
# No extension
|
|
738
|
+
return FilenameParts(directory=directory, basename=filename, extension="")
|
|
739
|
+
|
|
740
|
+
def _build_suffix_glob_pattern(self, directory: Path, basename: str, extension: str) -> str:
|
|
741
|
+
"""Build glob pattern for suffix injection strategy.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
directory: Parent directory
|
|
745
|
+
basename: Filename without extension
|
|
746
|
+
extension: File extension including dot
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
Glob pattern string
|
|
750
|
+
|
|
751
|
+
Examples:
|
|
752
|
+
("render", ".png") → "render_*.png"
|
|
753
|
+
("file", "") → "file_*"
|
|
754
|
+
"""
|
|
755
|
+
if extension:
|
|
756
|
+
return str(directory / f"{basename}_*{extension}")
|
|
757
|
+
return str(directory / f"{basename}_*")
|
|
758
|
+
|
|
759
|
+
def _extract_suffix_index(self, filename: str, basename: str, extension: str) -> int | None:
|
|
760
|
+
"""Extract numeric index from suffix in filename.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
filename: Full filename (e.g., "render_123.png")
|
|
764
|
+
basename: Expected base name (e.g., "render")
|
|
765
|
+
extension: Expected extension (e.g., ".png")
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
Integer index if found, None otherwise
|
|
769
|
+
|
|
770
|
+
Examples:
|
|
771
|
+
("render_123.png", "render", ".png") → 123
|
|
772
|
+
("render_1.png", "render", ".png") → 1
|
|
773
|
+
("render.png", "render", ".png") → None (no suffix)
|
|
774
|
+
("other_123.png", "render", ".png") → None (different basename)
|
|
775
|
+
"""
|
|
776
|
+
# Remove extension if present
|
|
777
|
+
if extension and filename.endswith(extension):
|
|
778
|
+
name_without_ext = filename[: -len(extension)]
|
|
779
|
+
else:
|
|
780
|
+
name_without_ext = filename
|
|
781
|
+
|
|
782
|
+
# Check if it starts with basename
|
|
783
|
+
expected_prefix = f"{basename}_"
|
|
784
|
+
if not name_without_ext.startswith(expected_prefix):
|
|
785
|
+
return None
|
|
786
|
+
|
|
787
|
+
# Extract suffix after basename_
|
|
788
|
+
suffix = name_without_ext[len(expected_prefix) :]
|
|
789
|
+
|
|
790
|
+
# Try to parse as integer
|
|
791
|
+
if suffix.isdigit():
|
|
792
|
+
return int(suffix)
|
|
793
|
+
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
def _convert_str_path_to_macro_with_index(self, path_str: str) -> MacroPath:
|
|
797
|
+
"""Convert string path to MacroPath with required {_index} variable for indexed filenames.
|
|
798
|
+
|
|
799
|
+
This is used when the base filename (without index) is already taken.
|
|
800
|
+
Converts paths like "/outputs/render.png" to template "/outputs/render_{_index}.png".
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
path_str: String path like "/outputs/render.png"
|
|
804
|
+
|
|
805
|
+
Returns:
|
|
806
|
+
MacroPath with required _index variable for indexed filenames
|
|
807
|
+
|
|
808
|
+
Examples:
|
|
809
|
+
Input: "/outputs/render.png"
|
|
810
|
+
Output: MacroPath with template "/outputs/render_{_index}.png"
|
|
811
|
+
Behavior: render_1.png → render_2.png → render_3.png → ...
|
|
812
|
+
|
|
813
|
+
Input: "/outputs/file"
|
|
814
|
+
Output: MacroPath with template "/outputs/file_{_index}"
|
|
815
|
+
Behavior: file_1 → file_2 → file_3 → ...
|
|
816
|
+
|
|
817
|
+
Note:
|
|
818
|
+
The base filename (e.g., "render.png") should be tried first before
|
|
819
|
+
using this template for indexed filenames.
|
|
820
|
+
"""
|
|
821
|
+
path = Path(path_str)
|
|
822
|
+
stem = path.stem
|
|
823
|
+
suffix = path.suffix
|
|
824
|
+
parent = str(path.parent)
|
|
825
|
+
|
|
826
|
+
if suffix:
|
|
827
|
+
template = f"{parent}/{stem}_{{_index}}{suffix}"
|
|
828
|
+
else:
|
|
829
|
+
template = f"{parent}/{stem}_{{_index}}"
|
|
830
|
+
|
|
831
|
+
parsed_macro = ParsedMacro(template)
|
|
832
|
+
|
|
833
|
+
return MacroPath(parsed_macro=parsed_macro, variables={})
|
|
834
|
+
|
|
835
|
+
def _scan_for_next_available_index(
|
|
836
|
+
self,
|
|
837
|
+
parsed_macro: ParsedMacro,
|
|
838
|
+
variables: dict[str, str | int],
|
|
839
|
+
index_var: ParsedVariable,
|
|
840
|
+
) -> int | None:
|
|
841
|
+
"""Scan existing files and return next available index (preview only - no file creation).
|
|
842
|
+
|
|
843
|
+
Uses fill-gaps strategy: if indices 1, 2, 4 exist, returns 3.
|
|
844
|
+
If index variable is optional and base filename is free, returns None.
|
|
845
|
+
|
|
846
|
+
This is a preview method - it ONLY scans the filesystem and returns a suggestion.
|
|
847
|
+
It does NOT create any files or acquire any locks.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
parsed_macro: Parsed macro template
|
|
851
|
+
variables: Known variable values (index variable NOT included)
|
|
852
|
+
index_var: The parsed variable to use for auto-incrementing
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
Next available index (1, 2, 3...), or None if index is optional and base filename is free
|
|
856
|
+
|
|
857
|
+
Examples:
|
|
858
|
+
Optional index with base file free:
|
|
859
|
+
Template: "/outputs/render{_index?:_}.png"
|
|
860
|
+
Files: ["/outputs/other.png"]
|
|
861
|
+
Returns: None (use base filename "/outputs/render.png")
|
|
862
|
+
|
|
863
|
+
Optional index with base file taken:
|
|
864
|
+
Template: "/outputs/render{_index?:_}.png"
|
|
865
|
+
Files: ["/outputs/render.png"]
|
|
866
|
+
Returns: 1 (use "/outputs/render_1.png")
|
|
867
|
+
|
|
868
|
+
Fill gaps strategy:
|
|
869
|
+
Template: "/outputs/render{_index:03}.png"
|
|
870
|
+
Files: ["/outputs/render001.png", "/outputs/render002.png", "/outputs/render004.png"]
|
|
871
|
+
Returns: 3 (fill the gap)
|
|
872
|
+
|
|
873
|
+
No existing files:
|
|
874
|
+
Template: "/outputs/render{_index:03}.png"
|
|
875
|
+
Files: []
|
|
876
|
+
Returns: 1 (start with index 1)
|
|
877
|
+
"""
|
|
878
|
+
secrets_manager = GriptapeNodes.SecretsManager()
|
|
879
|
+
index_var_name = index_var.info.name
|
|
880
|
+
|
|
881
|
+
# Check if index variable is optional
|
|
882
|
+
is_optional = not index_var.info.is_required
|
|
883
|
+
|
|
884
|
+
if is_optional:
|
|
885
|
+
# Try to resolve without the index variable to get base filename
|
|
886
|
+
try:
|
|
887
|
+
base_resolved = parsed_macro.resolve(variables, secrets_manager)
|
|
888
|
+
base_path = Path(base_resolved)
|
|
889
|
+
if not base_path.exists():
|
|
890
|
+
return None # Use base filename (no index)
|
|
891
|
+
except MacroResolutionError:
|
|
892
|
+
# Cannot resolve without index - treat as required
|
|
893
|
+
pass
|
|
894
|
+
|
|
895
|
+
# Build glob pattern by partially resolving with known variables
|
|
896
|
+
partial = partial_resolve(parsed_macro.template, parsed_macro.segments, variables, secrets_manager)
|
|
897
|
+
glob_pattern = self._build_glob_pattern_from_partially_resolved(partial.segments, index_var_name)
|
|
898
|
+
|
|
899
|
+
# Scan existing files matching pattern
|
|
900
|
+
glob_path = Path(glob_pattern)
|
|
901
|
+
if not glob_path.parent.exists():
|
|
902
|
+
# Parent directory doesn't exist - start at index 1
|
|
903
|
+
return 1
|
|
904
|
+
|
|
905
|
+
existing_files = list(glob_path.parent.glob(glob_path.name))
|
|
906
|
+
existing_indices = []
|
|
907
|
+
|
|
908
|
+
for filepath in existing_files:
|
|
909
|
+
filename = Path(filepath).name
|
|
910
|
+
extracted_index = self._extract_index_from_filename(filename, parsed_macro, index_var_name, variables)
|
|
911
|
+
if extracted_index is not None:
|
|
912
|
+
existing_indices.append(extracted_index)
|
|
913
|
+
|
|
914
|
+
if not existing_indices:
|
|
915
|
+
# No existing indexed files - start at 1
|
|
916
|
+
return 1
|
|
917
|
+
|
|
918
|
+
# Sort indices to find first gap
|
|
919
|
+
existing_indices.sort()
|
|
920
|
+
|
|
921
|
+
# Find first gap starting from 1
|
|
922
|
+
for i in range(1, max(existing_indices) + 1):
|
|
923
|
+
if i not in existing_indices:
|
|
924
|
+
return i # Found a gap
|
|
925
|
+
|
|
926
|
+
# No gaps - use max + 1
|
|
927
|
+
return max(existing_indices) + 1
|
|
928
|
+
|
|
302
929
|
def _validate_read_file_request(self, request: ReadFileRequest) -> tuple[Path, str]:
|
|
303
930
|
"""Validate read file request and return resolved file path and path string."""
|
|
304
931
|
# Validate that exactly one of file_path or file_entry is provided
|
|
@@ -322,6 +949,9 @@ class OSManager:
|
|
|
322
949
|
logger.error(msg)
|
|
323
950
|
raise ValueError(msg)
|
|
324
951
|
|
|
952
|
+
# Sanitize path to handle shell escapes and quotes (e.g., from macOS Finder "Copy as Pathname")
|
|
953
|
+
file_path_str = self.sanitize_path_string(file_path_str)
|
|
954
|
+
|
|
325
955
|
file_path = self._resolve_file_path(file_path_str, workspace_only=request.workspace_only is True)
|
|
326
956
|
|
|
327
957
|
# Check if file exists and is actually a file
|
|
@@ -675,7 +1305,11 @@ class OSManager:
|
|
|
675
1305
|
if not is_binary_file:
|
|
676
1306
|
content, encoding = self._read_text_file(file_path, request.encoding)
|
|
677
1307
|
else:
|
|
678
|
-
content, encoding = self._read_binary_file(
|
|
1308
|
+
content, encoding = self._read_binary_file(
|
|
1309
|
+
file_path,
|
|
1310
|
+
mime_type,
|
|
1311
|
+
should_transform_to_thumbnail=request.should_transform_image_content_to_thumbnail,
|
|
1312
|
+
)
|
|
679
1313
|
|
|
680
1314
|
return FileContentResult(
|
|
681
1315
|
content=content,
|
|
@@ -698,17 +1332,19 @@ class OSManager:
|
|
|
698
1332
|
with file_path.open("rb") as f:
|
|
699
1333
|
return f.read(), None
|
|
700
1334
|
|
|
701
|
-
def _read_binary_file(
|
|
702
|
-
|
|
1335
|
+
def _read_binary_file(
|
|
1336
|
+
self, file_path: Path, mime_type: str, *, should_transform_to_thumbnail: bool
|
|
1337
|
+
) -> tuple[bytes | str, None]:
|
|
1338
|
+
"""Read file as binary, with optional thumbnail generation for images."""
|
|
703
1339
|
with file_path.open("rb") as f:
|
|
704
1340
|
content = f.read()
|
|
705
1341
|
|
|
706
|
-
if mime_type.startswith("image/"):
|
|
707
|
-
content = self.
|
|
1342
|
+
if mime_type.startswith("image/") and should_transform_to_thumbnail:
|
|
1343
|
+
content = self._generate_thumbnail_from_image_content(content, file_path, mime_type)
|
|
708
1344
|
|
|
709
1345
|
return content, None
|
|
710
1346
|
|
|
711
|
-
def
|
|
1347
|
+
def _generate_thumbnail_from_image_content(self, content: bytes, file_path: Path, mime_type: str) -> str:
|
|
712
1348
|
"""Handle image content by creating previews or returning static URLs."""
|
|
713
1349
|
# Store original bytes for preview creation
|
|
714
1350
|
original_image_bytes = content
|
|
@@ -746,119 +1382,547 @@ class OSManager:
|
|
|
746
1382
|
else:
|
|
747
1383
|
return static_url
|
|
748
1384
|
|
|
749
|
-
def
|
|
750
|
-
"""Handle a request to
|
|
751
|
-
#
|
|
752
|
-
if request.
|
|
753
|
-
|
|
1385
|
+
def on_get_next_unused_filename_request(self, request: GetNextUnusedFilenameRequest) -> ResultPayload:
|
|
1386
|
+
"""Handle a request to find the next available filename (preview only - no file creation)."""
|
|
1387
|
+
# Handle string paths specially: try base path first, then indexed
|
|
1388
|
+
if isinstance(request.file_path, str):
|
|
1389
|
+
# First, check if base path is available
|
|
1390
|
+
try:
|
|
1391
|
+
base_path = self._resolve_file_path(request.file_path, workspace_only=False)
|
|
1392
|
+
except (ValueError, RuntimeError) as e:
|
|
1393
|
+
msg = f"Invalid path: {e}"
|
|
1394
|
+
logger.error(msg)
|
|
1395
|
+
return GetNextUnusedFilenameResultFailure(
|
|
1396
|
+
failure_reason=FileIOFailureReason.INVALID_PATH,
|
|
1397
|
+
result_details=msg,
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
if not base_path.exists():
|
|
1401
|
+
# Base filename is available - use it
|
|
1402
|
+
return GetNextUnusedFilenameResultSuccess(
|
|
1403
|
+
available_filename=str(base_path),
|
|
1404
|
+
index_used=None,
|
|
1405
|
+
result_details="Found available filename (no index needed)",
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
# Base filename taken - convert to indexed MacroPath and scan
|
|
1409
|
+
macro_path = self._convert_str_path_to_macro_with_index(request.file_path)
|
|
1410
|
+
else:
|
|
1411
|
+
# MacroPath provided directly
|
|
1412
|
+
macro_path = request.file_path
|
|
1413
|
+
|
|
1414
|
+
parsed_macro = macro_path.parsed_macro
|
|
1415
|
+
variables = macro_path.variables
|
|
1416
|
+
|
|
1417
|
+
# Identify index variable
|
|
1418
|
+
try:
|
|
1419
|
+
index_info = self._identify_index_variable(parsed_macro, variables)
|
|
1420
|
+
except ValueError as e:
|
|
1421
|
+
msg = str(e)
|
|
754
1422
|
logger.error(msg)
|
|
755
|
-
return
|
|
756
|
-
failure_reason=FileIOFailureReason.
|
|
1423
|
+
return GetNextUnusedFilenameResultFailure(
|
|
1424
|
+
failure_reason=FileIOFailureReason.INVALID_PATH,
|
|
757
1425
|
result_details=msg,
|
|
758
1426
|
)
|
|
759
1427
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
except (ValueError, RuntimeError) as e:
|
|
764
|
-
msg = f"Invalid path: {e}"
|
|
1428
|
+
if index_info is None:
|
|
1429
|
+
# No unresolved variables - cannot auto-increment
|
|
1430
|
+
msg = "No index variable found in path template"
|
|
765
1431
|
logger.error(msg)
|
|
766
|
-
return
|
|
1432
|
+
return GetNextUnusedFilenameResultFailure(
|
|
1433
|
+
failure_reason=FileIOFailureReason.INVALID_PATH,
|
|
1434
|
+
result_details=msg,
|
|
1435
|
+
)
|
|
767
1436
|
|
|
768
|
-
#
|
|
769
|
-
|
|
1437
|
+
# Scan for next available index (preview only - no file creation)
|
|
1438
|
+
next_index = self._scan_for_next_available_index(parsed_macro, variables, index_info)
|
|
770
1439
|
|
|
771
|
-
#
|
|
1440
|
+
# Resolve path with the index
|
|
1441
|
+
secrets_manager = GriptapeNodes.SecretsManager()
|
|
772
1442
|
try:
|
|
773
|
-
if
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1443
|
+
if next_index is None:
|
|
1444
|
+
# Optional index variable with base filename available
|
|
1445
|
+
available_filename = parsed_macro.resolve(variables, secrets_manager)
|
|
1446
|
+
else:
|
|
1447
|
+
# Use indexed filename
|
|
1448
|
+
index_vars = {**variables, index_info.info.name: next_index}
|
|
1449
|
+
available_filename = parsed_macro.resolve(index_vars, secrets_manager)
|
|
1450
|
+
except MacroResolutionError as e:
|
|
1451
|
+
msg = f"Failed to resolve path template: {e}"
|
|
779
1452
|
logger.error(msg)
|
|
780
|
-
return
|
|
1453
|
+
return GetNextUnusedFilenameResultFailure(
|
|
1454
|
+
failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES,
|
|
1455
|
+
result_details=msg,
|
|
1456
|
+
)
|
|
781
1457
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1458
|
+
return GetNextUnusedFilenameResultSuccess(
|
|
1459
|
+
available_filename=available_filename,
|
|
1460
|
+
index_used=next_index,
|
|
1461
|
+
result_details=f"Found available filename with index {next_index}"
|
|
1462
|
+
if next_index
|
|
1463
|
+
else "Found available filename (no index needed)",
|
|
1464
|
+
)
|
|
1465
|
+
|
|
1466
|
+
def on_write_file_request(self, request: WriteFileRequest) -> ResultPayload: # noqa: PLR0911, PLR0912, PLR0915, C901
|
|
1467
|
+
"""Handle a request to write content to a file with exclusive locking."""
|
|
1468
|
+
# Initialize success tracking variables
|
|
1469
|
+
final_file_path: Path | None = None
|
|
1470
|
+
final_bytes_written: int | None = None
|
|
1471
|
+
used_indexed_fallback = False
|
|
1472
|
+
|
|
1473
|
+
# COMMON SETUP: Resolve path for all policies
|
|
1474
|
+
# Resolve MacroPath → str
|
|
1475
|
+
if isinstance(request.file_path, MacroPath):
|
|
1476
|
+
resolution_result = self._resolve_macro_path_to_string(request.file_path)
|
|
1477
|
+
if isinstance(resolution_result, MacroResolutionFailure):
|
|
1478
|
+
path_display = f"{request.file_path.parsed_macro}"
|
|
1479
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to missing variables: {resolution_result.error_details}"
|
|
1480
|
+
return WriteFileResultFailure(
|
|
1481
|
+
failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES,
|
|
1482
|
+
missing_variables=resolution_result.missing_variables,
|
|
1483
|
+
result_details=msg,
|
|
1484
|
+
)
|
|
1485
|
+
resolved_path_str = resolution_result
|
|
1486
|
+
path_display = f"{request.file_path.parsed_macro}"
|
|
1487
|
+
else:
|
|
1488
|
+
# Sanitize string path (removes shell escapes, quotes, etc.)
|
|
1489
|
+
resolved_path_str = self.sanitize_path_string(request.file_path)
|
|
1490
|
+
path_display = resolved_path_str
|
|
1491
|
+
|
|
1492
|
+
# Convert str → Path
|
|
1493
|
+
try:
|
|
1494
|
+
file_path = self._resolve_file_path(resolved_path_str, workspace_only=False)
|
|
1495
|
+
except (ValueError, RuntimeError) as e:
|
|
1496
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to invalid path: {e}"
|
|
1497
|
+
return WriteFileResultFailure(
|
|
1498
|
+
failure_reason=FileIOFailureReason.INVALID_PATH,
|
|
1499
|
+
result_details=msg,
|
|
1500
|
+
)
|
|
1501
|
+
except Exception as e:
|
|
1502
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to unexpected error: {e}"
|
|
1503
|
+
return WriteFileResultFailure(
|
|
1504
|
+
failure_reason=FileIOFailureReason.IO_ERROR,
|
|
1505
|
+
result_details=msg,
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
# Ensure parent directory is ready
|
|
1509
|
+
parent_failure_reason = self._ensure_parent_directory_ready(
|
|
1510
|
+
file_path,
|
|
1511
|
+
create_parents=request.create_parents,
|
|
1512
|
+
)
|
|
1513
|
+
if parent_failure_reason is not None:
|
|
1514
|
+
match parent_failure_reason:
|
|
1515
|
+
case FileIOFailureReason.PERMISSION_DENIED:
|
|
1516
|
+
msg = f"Attempted to write to file '{file_path}'. Failed due to permission denied creating parent directory {file_path.parent}"
|
|
1517
|
+
case FileIOFailureReason.POLICY_NO_CREATE_PARENT_DIRS:
|
|
1518
|
+
msg = f"Attempted to write to file '{file_path}'. Failed due to the parent directory not existing, and a policy was specified to NOT create parent directories: {file_path.parent}"
|
|
1519
|
+
case _:
|
|
1520
|
+
msg = f"Attempted to write to file '{file_path}'. Failed due to error creating parent directory {file_path.parent}"
|
|
1521
|
+
return WriteFileResultFailure(
|
|
1522
|
+
failure_reason=parent_failure_reason,
|
|
1523
|
+
result_details=msg,
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
# Normalize path
|
|
1527
|
+
normalized_path = self.normalize_path_for_platform(file_path)
|
|
1528
|
+
|
|
1529
|
+
# Now attempt the write, based on our collision (existing file) policy.
|
|
1530
|
+
match request.existing_file_policy:
|
|
1531
|
+
case ExistingFilePolicy.FAIL | ExistingFilePolicy.OVERWRITE:
|
|
1532
|
+
# Path already validated and ready to use
|
|
1533
|
+
|
|
1534
|
+
# Determine write mode based on policy
|
|
1535
|
+
if request.existing_file_policy == ExistingFilePolicy.FAIL:
|
|
1536
|
+
mode = "x" # Exclusive creation (fail if exists)
|
|
1537
|
+
else:
|
|
1538
|
+
mode = "a" if request.append else "w" # Append or overwrite
|
|
1539
|
+
|
|
1540
|
+
# Perform the write operation using helper
|
|
1541
|
+
result = self._attempt_file_write(
|
|
1542
|
+
normalized_path=Path(normalized_path),
|
|
1543
|
+
content=request.content,
|
|
1544
|
+
encoding=request.encoding,
|
|
1545
|
+
mode=mode,
|
|
1546
|
+
file_path_display=file_path,
|
|
1547
|
+
fail_if_file_exists=True, # FAIL policy always fails on file exists
|
|
1548
|
+
fail_if_file_locked=True,
|
|
1549
|
+
)
|
|
1550
|
+
if result.failure_reason is not None:
|
|
1551
|
+
# error_message is guaranteed to be set when failure_reason is set
|
|
789
1552
|
return WriteFileResultFailure(
|
|
790
|
-
failure_reason=
|
|
791
|
-
result_details=
|
|
1553
|
+
failure_reason=result.failure_reason,
|
|
1554
|
+
result_details=result.error_message, # type: ignore[arg-type]
|
|
792
1555
|
)
|
|
793
|
-
except OSError as e:
|
|
794
|
-
msg = f"Error checking if file exists {file_path}: {e}"
|
|
795
|
-
logger.error(msg)
|
|
796
|
-
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
797
1556
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1557
|
+
# Success - set variables for return at end
|
|
1558
|
+
final_file_path = file_path
|
|
1559
|
+
final_bytes_written = result.bytes_written
|
|
1560
|
+
|
|
1561
|
+
case ExistingFilePolicy.CREATE_NEW:
|
|
1562
|
+
# Path already validated and ready to use (handled at method top)
|
|
1563
|
+
|
|
1564
|
+
# TRY-FIRST: Attempt to write to the requested path
|
|
1565
|
+
result = self._attempt_file_write(
|
|
1566
|
+
normalized_path=Path(normalized_path),
|
|
1567
|
+
content=request.content,
|
|
1568
|
+
encoding=request.encoding,
|
|
1569
|
+
mode="x",
|
|
1570
|
+
file_path_display=file_path,
|
|
1571
|
+
fail_if_file_exists=False, # Fall back to indexed
|
|
1572
|
+
fail_if_file_locked=False, # Fall back to indexed
|
|
1573
|
+
)
|
|
1574
|
+
if result.failure_reason is not None:
|
|
1575
|
+
# error_message is guaranteed to be set when failure_reason is set
|
|
805
1576
|
return WriteFileResultFailure(
|
|
806
|
-
failure_reason=
|
|
807
|
-
result_details=
|
|
1577
|
+
failure_reason=result.failure_reason,
|
|
1578
|
+
result_details=result.error_message, # type: ignore[arg-type]
|
|
808
1579
|
)
|
|
1580
|
+
if result.bytes_written is not None:
|
|
1581
|
+
# Success on first try!
|
|
1582
|
+
final_file_path = file_path
|
|
1583
|
+
final_bytes_written = result.bytes_written
|
|
1584
|
+
else:
|
|
1585
|
+
# FILE EXISTS OR IS LOCKED. ATTEMPT TO FIND THE NEXT AVAILABLE.
|
|
1586
|
+
# Convert to indexed MacroPath for scanning. If the user didn't give us a macro to start with,
|
|
1587
|
+
# we'll take their file name and turn it into a macro that appends _<index> to it.
|
|
1588
|
+
# (e.g., if they gave us "output.png" we'll convert that to a macro that tries "output_1.png", "output_2.png", etc.)
|
|
1589
|
+
macro_path = (
|
|
1590
|
+
self._convert_str_path_to_macro_with_index(request.file_path)
|
|
1591
|
+
if isinstance(request.file_path, str)
|
|
1592
|
+
else request.file_path
|
|
1593
|
+
)
|
|
1594
|
+
parsed_macro = macro_path.parsed_macro
|
|
1595
|
+
variables = macro_path.variables
|
|
1596
|
+
|
|
1597
|
+
# Identify index variable
|
|
1598
|
+
try:
|
|
1599
|
+
index_info = self._identify_index_variable(parsed_macro, variables)
|
|
1600
|
+
except ValueError as e:
|
|
1601
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to {e}"
|
|
1602
|
+
return WriteFileResultFailure(
|
|
1603
|
+
failure_reason=FileIOFailureReason.INVALID_PATH,
|
|
1604
|
+
result_details=msg,
|
|
1605
|
+
)
|
|
1606
|
+
except Exception as e:
|
|
1607
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to unexpected error: {e}"
|
|
1608
|
+
return WriteFileResultFailure(
|
|
1609
|
+
failure_reason=FileIOFailureReason.IO_ERROR,
|
|
1610
|
+
result_details=msg,
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
if index_info is None:
|
|
1614
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to no index variable found in path template"
|
|
1615
|
+
return WriteFileResultFailure(
|
|
1616
|
+
failure_reason=FileIOFailureReason.INVALID_PATH,
|
|
1617
|
+
result_details=msg,
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
# We have a macro with one and only one index variable on it. The heuristic here is:
|
|
1621
|
+
# 1. Find the FIRST available file name with our index. We'll start there, but someone else may have
|
|
1622
|
+
# ganked it while we were attempting to write to it.
|
|
1623
|
+
# 2. Try candidates in sequence until we find one that works, or fail if we've tried too many times.
|
|
1624
|
+
# Note: The user could have specified using the index value as a DIRECTORY,
|
|
1625
|
+
# so it's not always output_1, output_2, etc. It could be run_1/output.png, run_2/output.png, etc.
|
|
1626
|
+
|
|
1627
|
+
# Scan for starting index
|
|
1628
|
+
starting_index = self._scan_for_next_available_index(parsed_macro, variables, index_info)
|
|
1629
|
+
|
|
1630
|
+
# Try indexed candidates on-demand (up to max attempts)
|
|
1631
|
+
secrets_manager = GriptapeNodes.SecretsManager()
|
|
1632
|
+
start_idx = starting_index if starting_index is not None else 1
|
|
1633
|
+
attempted_count = 0
|
|
1634
|
+
|
|
1635
|
+
for idx in range(start_idx, start_idx + MAX_INDEXED_CANDIDATES):
|
|
1636
|
+
attempted_count += 1
|
|
1637
|
+
|
|
1638
|
+
# Step 1: Resolve macro with current index
|
|
1639
|
+
try:
|
|
1640
|
+
index_vars = {**variables, index_info.info.name: idx}
|
|
1641
|
+
candidate_str = parsed_macro.resolve(index_vars, secrets_manager)
|
|
1642
|
+
except MacroResolutionError as e:
|
|
1643
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to unable to resolve path template with index {idx}: {e}"
|
|
1644
|
+
return WriteFileResultFailure(
|
|
1645
|
+
failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES,
|
|
1646
|
+
result_details=msg,
|
|
1647
|
+
)
|
|
1648
|
+
except Exception as e:
|
|
1649
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to unexpected error: {e}"
|
|
1650
|
+
return WriteFileResultFailure(
|
|
1651
|
+
failure_reason=FileIOFailureReason.IO_ERROR,
|
|
1652
|
+
result_details=msg,
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
# Step 2: Resolve file path
|
|
1656
|
+
try:
|
|
1657
|
+
candidate_path = self._resolve_file_path(candidate_str, workspace_only=False)
|
|
1658
|
+
except (ValueError, RuntimeError) as e:
|
|
1659
|
+
msg = f"Attempted to write to file '{candidate_str}'. Failed due to invalid path: {e}"
|
|
1660
|
+
return WriteFileResultFailure(
|
|
1661
|
+
failure_reason=FileIOFailureReason.INVALID_PATH,
|
|
1662
|
+
result_details=msg,
|
|
1663
|
+
)
|
|
1664
|
+
except Exception as e:
|
|
1665
|
+
msg = f"Attempted to write to file '{candidate_str}'. Failed due to unexpected error: {e}"
|
|
1666
|
+
return WriteFileResultFailure(
|
|
1667
|
+
failure_reason=FileIOFailureReason.IO_ERROR,
|
|
1668
|
+
result_details=msg,
|
|
1669
|
+
)
|
|
809
1670
|
|
|
810
|
-
|
|
811
|
-
|
|
1671
|
+
# Ensure parent directory for this candidate
|
|
1672
|
+
parent_failure_reason = self._ensure_parent_directory_ready(
|
|
1673
|
+
candidate_path,
|
|
1674
|
+
create_parents=request.create_parents,
|
|
1675
|
+
)
|
|
1676
|
+
if parent_failure_reason is not None:
|
|
1677
|
+
match parent_failure_reason:
|
|
1678
|
+
case FileIOFailureReason.PERMISSION_DENIED:
|
|
1679
|
+
msg = f"Attempted to write to file '{candidate_path}'. Failed due to permission denied creating parent directory {candidate_path.parent}"
|
|
1680
|
+
case FileIOFailureReason.POLICY_NO_CREATE_PARENT_DIRS:
|
|
1681
|
+
msg = f"Attempted to write to file '{candidate_path}'. Failed due to the parent directory not existing, and a policy was specified to NOT create parent directories: {candidate_path.parent}"
|
|
1682
|
+
case _:
|
|
1683
|
+
msg = f"Attempted to write to file '{candidate_path}'. Failed due to error creating parent directory {candidate_path.parent}"
|
|
1684
|
+
return WriteFileResultFailure(
|
|
1685
|
+
failure_reason=parent_failure_reason,
|
|
1686
|
+
result_details=msg,
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
normalized_candidate_path = self.normalize_path_for_platform(candidate_path)
|
|
1690
|
+
|
|
1691
|
+
# Try to write this indexed candidate using helper
|
|
1692
|
+
result = self._attempt_file_write(
|
|
1693
|
+
normalized_path=Path(normalized_candidate_path),
|
|
1694
|
+
content=request.content,
|
|
1695
|
+
encoding=request.encoding,
|
|
1696
|
+
mode="x",
|
|
1697
|
+
file_path_display=candidate_path,
|
|
1698
|
+
fail_if_file_exists=False, # Try next candidate
|
|
1699
|
+
fail_if_file_locked=False, # Try next candidate
|
|
1700
|
+
)
|
|
1701
|
+
if result.failure_reason is not None:
|
|
1702
|
+
# error_message is guaranteed to be set when failure_reason is set
|
|
1703
|
+
return WriteFileResultFailure(
|
|
1704
|
+
failure_reason=result.failure_reason,
|
|
1705
|
+
result_details=result.error_message, # type: ignore[arg-type]
|
|
1706
|
+
)
|
|
1707
|
+
if result.bytes_written is not None:
|
|
1708
|
+
# Success with indexed path!
|
|
1709
|
+
final_file_path = candidate_path
|
|
1710
|
+
final_bytes_written = result.bytes_written
|
|
1711
|
+
used_indexed_fallback = True
|
|
1712
|
+
break
|
|
1713
|
+
# else: continue to next candidate
|
|
1714
|
+
|
|
1715
|
+
# Check if we exhausted all indexed candidates
|
|
1716
|
+
if final_file_path is None:
|
|
1717
|
+
msg = f"Attempted to write to file '{path_display}'. Failed due to could not find available filename after trying {attempted_count} candidates"
|
|
1718
|
+
return WriteFileResultFailure(
|
|
1719
|
+
failure_reason=FileIOFailureReason.IO_ERROR,
|
|
1720
|
+
result_details=msg,
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
# SUCCESS PATH: All three policies converge here
|
|
1724
|
+
if final_file_path is None or final_bytes_written is None:
|
|
1725
|
+
msg = "Internal error: success path reached but file path or bytes not set"
|
|
1726
|
+
raise RuntimeError(msg)
|
|
1727
|
+
|
|
1728
|
+
if used_indexed_fallback:
|
|
1729
|
+
msg = f"File written to indexed path: {final_file_path} (original path '{path_display}' already existed)"
|
|
1730
|
+
result_details = ResultDetails(message=msg, level=logging.WARNING)
|
|
1731
|
+
else:
|
|
1732
|
+
result_details = f"File written successfully: {final_file_path}"
|
|
1733
|
+
|
|
1734
|
+
return WriteFileResultSuccess(
|
|
1735
|
+
final_file_path=str(final_file_path),
|
|
1736
|
+
bytes_written=final_bytes_written,
|
|
1737
|
+
result_details=result_details,
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
def _ensure_parent_directory_ready(
|
|
1741
|
+
self,
|
|
1742
|
+
file_path: Path,
|
|
1743
|
+
*,
|
|
1744
|
+
create_parents: bool,
|
|
1745
|
+
) -> FileIOFailureReason | None:
|
|
1746
|
+
"""Ensure parent directory exists or create it.
|
|
1747
|
+
|
|
1748
|
+
Args:
|
|
1749
|
+
file_path: The file path whose parent should be validated/created
|
|
1750
|
+
create_parents: If True, create parent dirs; if False, validate they exist
|
|
1751
|
+
|
|
1752
|
+
Returns:
|
|
1753
|
+
None on success, FileIOFailureReason if validation/creation fails
|
|
1754
|
+
"""
|
|
1755
|
+
if create_parents:
|
|
1756
|
+
parent_normalized = self.normalize_path_for_platform(file_path.parent)
|
|
1757
|
+
try:
|
|
1758
|
+
if not Path(parent_normalized).exists():
|
|
1759
|
+
Path(parent_normalized).mkdir(parents=True, exist_ok=True)
|
|
1760
|
+
except PermissionError:
|
|
1761
|
+
return FileIOFailureReason.PERMISSION_DENIED
|
|
1762
|
+
except OSError:
|
|
1763
|
+
return FileIOFailureReason.IO_ERROR
|
|
1764
|
+
elif not file_path.parent.exists():
|
|
1765
|
+
return FileIOFailureReason.POLICY_NO_CREATE_PARENT_DIRS
|
|
1766
|
+
|
|
1767
|
+
return None
|
|
1768
|
+
|
|
1769
|
+
def _attempt_file_write( # noqa: PLR0911, PLR0913
|
|
1770
|
+
self,
|
|
1771
|
+
normalized_path: Path,
|
|
1772
|
+
content: str | bytes,
|
|
1773
|
+
encoding: str,
|
|
1774
|
+
mode: str,
|
|
1775
|
+
file_path_display: str | Path,
|
|
1776
|
+
*,
|
|
1777
|
+
fail_if_file_exists: bool,
|
|
1778
|
+
fail_if_file_locked: bool,
|
|
1779
|
+
) -> FileWriteAttemptResult:
|
|
1780
|
+
"""Attempt to write a file with unified exception handling.
|
|
1781
|
+
|
|
1782
|
+
Args:
|
|
1783
|
+
normalized_path: The normalized path to write to
|
|
1784
|
+
content: Content to write (str or bytes)
|
|
1785
|
+
encoding: Encoding for text content
|
|
1786
|
+
mode: Write mode ("x", "w", "a")
|
|
1787
|
+
file_path_display: Path to use in error messages
|
|
1788
|
+
fail_if_file_exists: If True, return failure when file exists; if False, return continue signal
|
|
1789
|
+
fail_if_file_locked: If True, return failure when file is locked; if False, return continue signal
|
|
1790
|
+
|
|
1791
|
+
Returns:
|
|
1792
|
+
FileWriteAttemptResult with one of:
|
|
1793
|
+
- Success: bytes_written is set, failure_reason and error_message are None
|
|
1794
|
+
- Continue: all fields are None (file exists/locked but caller wants to continue)
|
|
1795
|
+
- Failure: failure_reason and error_message are set, bytes_written is None
|
|
1796
|
+
"""
|
|
1797
|
+
try:
|
|
1798
|
+
bytes_written = self._write_with_portalocker(
|
|
1799
|
+
str(normalized_path),
|
|
1800
|
+
content,
|
|
1801
|
+
encoding,
|
|
1802
|
+
mode=mode,
|
|
1803
|
+
)
|
|
1804
|
+
# Success!
|
|
1805
|
+
return FileWriteAttemptResult(
|
|
1806
|
+
bytes_written=bytes_written,
|
|
1807
|
+
failure_reason=None,
|
|
1808
|
+
error_message=None,
|
|
1809
|
+
)
|
|
1810
|
+
except FileExistsError:
|
|
1811
|
+
if fail_if_file_exists:
|
|
1812
|
+
msg = f"Attempted to write to file '{file_path_display}'. Failed due to file already exists (policy: fail if exists)"
|
|
1813
|
+
return FileWriteAttemptResult(
|
|
1814
|
+
bytes_written=None,
|
|
1815
|
+
failure_reason=FileIOFailureReason.POLICY_NO_OVERWRITE,
|
|
1816
|
+
error_message=msg,
|
|
1817
|
+
)
|
|
1818
|
+
# Continue signal - caller should try next candidate or fallback
|
|
1819
|
+
return FileWriteAttemptResult(
|
|
1820
|
+
bytes_written=None,
|
|
1821
|
+
failure_reason=None,
|
|
1822
|
+
error_message=None,
|
|
1823
|
+
)
|
|
1824
|
+
except portalocker.LockException:
|
|
1825
|
+
if fail_if_file_locked:
|
|
1826
|
+
msg = f"Attempted to write to file '{file_path_display}'. Failed due to file locked by another process"
|
|
1827
|
+
return FileWriteAttemptResult(
|
|
1828
|
+
bytes_written=None,
|
|
1829
|
+
failure_reason=FileIOFailureReason.FILE_LOCKED,
|
|
1830
|
+
error_message=msg,
|
|
1831
|
+
)
|
|
1832
|
+
# Continue signal - caller should try next candidate or fallback
|
|
1833
|
+
return FileWriteAttemptResult(
|
|
1834
|
+
bytes_written=None,
|
|
1835
|
+
failure_reason=None,
|
|
1836
|
+
error_message=None,
|
|
1837
|
+
)
|
|
812
1838
|
except PermissionError as e:
|
|
813
|
-
msg = f"
|
|
814
|
-
|
|
815
|
-
|
|
1839
|
+
msg = f"Attempted to write to file '{file_path_display}'. Failed due to permission denied: {e}"
|
|
1840
|
+
return FileWriteAttemptResult(
|
|
1841
|
+
bytes_written=None,
|
|
816
1842
|
failure_reason=FileIOFailureReason.PERMISSION_DENIED,
|
|
817
|
-
|
|
1843
|
+
error_message=msg,
|
|
818
1844
|
)
|
|
819
|
-
except
|
|
820
|
-
msg = f"
|
|
821
|
-
|
|
822
|
-
|
|
1845
|
+
except IsADirectoryError as e:
|
|
1846
|
+
msg = f"Attempted to write to file '{file_path_display}'. Failed due to path is a directory: {e}"
|
|
1847
|
+
return FileWriteAttemptResult(
|
|
1848
|
+
bytes_written=None,
|
|
1849
|
+
failure_reason=FileIOFailureReason.IS_DIRECTORY,
|
|
1850
|
+
error_message=msg,
|
|
1851
|
+
)
|
|
1852
|
+
except Exception as e:
|
|
1853
|
+
msg = f"Attempted to write to file '{file_path_display}'. Failed due to unexpected error: {e}"
|
|
1854
|
+
return FileWriteAttemptResult(
|
|
1855
|
+
bytes_written=None,
|
|
1856
|
+
failure_reason=FileIOFailureReason.IO_ERROR,
|
|
1857
|
+
error_message=msg,
|
|
1858
|
+
)
|
|
1859
|
+
|
|
1860
|
+
def _write_with_portalocker( # noqa: C901
|
|
1861
|
+
self, normalized_path: str, content: str | bytes, encoding: str, *, mode: str
|
|
1862
|
+
) -> int:
|
|
1863
|
+
"""Write content to a file with exclusive lock using portalocker.
|
|
1864
|
+
|
|
1865
|
+
Args:
|
|
1866
|
+
normalized_path: Normalized path string (with Windows long path prefix if needed)
|
|
1867
|
+
content: Content to write (str for text, bytes for binary)
|
|
1868
|
+
encoding: Text encoding (ignored for bytes)
|
|
1869
|
+
mode: File open mode ('x' for exclusive create, 'w' for overwrite, 'a' for append)
|
|
1870
|
+
|
|
1871
|
+
Returns:
|
|
1872
|
+
Number of bytes written
|
|
1873
|
+
|
|
1874
|
+
Raises:
|
|
1875
|
+
FileExistsError: If mode='x' and file already exists
|
|
1876
|
+
portalocker.LockException: If file is locked by another process
|
|
1877
|
+
PermissionError: If permission denied
|
|
1878
|
+
IsADirectoryError: If path is a directory
|
|
1879
|
+
UnicodeEncodeError: If encoding error occurs
|
|
1880
|
+
OSError: For other I/O errors
|
|
1881
|
+
"""
|
|
1882
|
+
error_details = None
|
|
823
1883
|
|
|
824
|
-
# Write file content
|
|
825
1884
|
try:
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1885
|
+
# Determine binary vs text mode
|
|
1886
|
+
if isinstance(content, bytes):
|
|
1887
|
+
file_mode = mode + "b"
|
|
1888
|
+
else:
|
|
1889
|
+
file_mode = mode
|
|
1890
|
+
|
|
1891
|
+
with portalocker.Lock(
|
|
1892
|
+
normalized_path,
|
|
1893
|
+
mode=file_mode, # type: ignore[arg-type]
|
|
1894
|
+
encoding=encoding if isinstance(content, str) else None,
|
|
1895
|
+
timeout=0, # Non-blocking
|
|
1896
|
+
flags=portalocker.LockFlags.EXCLUSIVE,
|
|
1897
|
+
) as fh:
|
|
1898
|
+
fh.write(content)
|
|
1899
|
+
|
|
1900
|
+
# Calculate bytes written
|
|
1901
|
+
if isinstance(content, bytes):
|
|
1902
|
+
return len(content)
|
|
1903
|
+
return len(content.encode(encoding))
|
|
1904
|
+
|
|
1905
|
+
except portalocker.LockException:
|
|
1906
|
+
raise
|
|
1907
|
+
except FileExistsError:
|
|
1908
|
+
raise
|
|
1909
|
+
except PermissionError:
|
|
1910
|
+
raise
|
|
833
1911
|
except IsADirectoryError:
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
except UnicodeEncodeError as e:
|
|
838
|
-
msg = f"Encoding error writing to file {file_path}: {e}"
|
|
839
|
-
logger.error(msg)
|
|
840
|
-
return WriteFileResultFailure(failure_reason=FileIOFailureReason.ENCODING_ERROR, result_details=msg)
|
|
1912
|
+
raise
|
|
1913
|
+
except UnicodeEncodeError:
|
|
1914
|
+
raise
|
|
841
1915
|
except OSError as e:
|
|
842
1916
|
# Check for disk full
|
|
843
1917
|
if "No space left" in str(e) or "Disk full" in str(e):
|
|
844
|
-
|
|
845
|
-
logger.error(
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
msg = f"I/O error writing to file {file_path}: {e}"
|
|
849
|
-
logger.error(msg)
|
|
850
|
-
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
1918
|
+
error_details = f"Disk full: {e}"
|
|
1919
|
+
logger.error(error_details)
|
|
1920
|
+
raise OSError(error_details) from e
|
|
1921
|
+
raise
|
|
851
1922
|
except Exception as e:
|
|
852
|
-
|
|
853
|
-
logger.error(
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
# SUCCESS PATH - Only reached if no exceptions occurred
|
|
857
|
-
return WriteFileResultSuccess(
|
|
858
|
-
final_file_path=str(file_path),
|
|
859
|
-
bytes_written=bytes_written,
|
|
860
|
-
result_details=f"File written successfully: {file_path}",
|
|
861
|
-
)
|
|
1923
|
+
error_details = f"Unexpected error: {type(e).__name__}: {e}"
|
|
1924
|
+
logger.error(error_details)
|
|
1925
|
+
raise
|
|
862
1926
|
|
|
863
1927
|
def _copy_file(self, src_path: Path, dest_path: Path) -> int:
|
|
864
1928
|
"""Copy a single file from source to destination with platform path normalization.
|
|
@@ -882,35 +1946,7 @@ class OSManager:
|
|
|
882
1946
|
shutil.copy2(src_normalized, dest_normalized)
|
|
883
1947
|
|
|
884
1948
|
# Return size of copied file
|
|
885
|
-
return
|
|
886
|
-
|
|
887
|
-
def _write_file_content(self, normalized_path: str, content: str | bytes, encoding: str, *, append: bool) -> int:
|
|
888
|
-
"""Write content to a file and return bytes written.
|
|
889
|
-
|
|
890
|
-
Args:
|
|
891
|
-
normalized_path: Normalized path string (with Windows long path prefix if needed)
|
|
892
|
-
content: Content to write (str for text, bytes for binary)
|
|
893
|
-
encoding: Text encoding (ignored for bytes)
|
|
894
|
-
append: If True, append to file; if False, overwrite
|
|
895
|
-
|
|
896
|
-
Returns:
|
|
897
|
-
Number of bytes written
|
|
898
|
-
"""
|
|
899
|
-
# Determine mode based on content type and append flag
|
|
900
|
-
if isinstance(content, bytes):
|
|
901
|
-
mode = "ab" if append else "wb"
|
|
902
|
-
# Use open() instead of Path.open() to support Windows long paths with \\?\ prefix
|
|
903
|
-
with open(normalized_path, mode) as f: # noqa: PTH123
|
|
904
|
-
f.write(content)
|
|
905
|
-
return len(content)
|
|
906
|
-
|
|
907
|
-
# Text content
|
|
908
|
-
mode = "a" if append else "w"
|
|
909
|
-
# Use open() instead of Path.open() to support Windows long paths with \\?\ prefix
|
|
910
|
-
with open(normalized_path, mode, encoding=encoding) as f: # noqa: PTH123
|
|
911
|
-
f.write(content)
|
|
912
|
-
# Return byte count for text (encoded size)
|
|
913
|
-
return len(content.encode(encoding))
|
|
1949
|
+
return Path(src_normalized).stat().st_size
|
|
914
1950
|
|
|
915
1951
|
@staticmethod
|
|
916
1952
|
def get_disk_space_info(path: Path) -> DiskSpaceInfo:
|