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.
Files changed (55) hide show
  1. griptape_nodes/app/app.py +25 -5
  2. griptape_nodes/cli/commands/init.py +65 -54
  3. griptape_nodes/cli/commands/libraries.py +92 -85
  4. griptape_nodes/cli/commands/self.py +121 -0
  5. griptape_nodes/common/node_executor.py +2142 -101
  6. griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
  7. griptape_nodes/exe_types/connections.py +114 -19
  8. griptape_nodes/exe_types/core_types.py +225 -7
  9. griptape_nodes/exe_types/flow.py +3 -3
  10. griptape_nodes/exe_types/node_types.py +681 -225
  11. griptape_nodes/exe_types/param_components/README.md +414 -0
  12. griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
  15. griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
  16. griptape_nodes/machines/control_flow.py +77 -38
  17. griptape_nodes/machines/dag_builder.py +148 -70
  18. griptape_nodes/machines/parallel_resolution.py +61 -35
  19. griptape_nodes/machines/sequential_resolution.py +11 -113
  20. griptape_nodes/retained_mode/events/app_events.py +1 -0
  21. griptape_nodes/retained_mode/events/base_events.py +16 -13
  22. griptape_nodes/retained_mode/events/connection_events.py +3 -0
  23. griptape_nodes/retained_mode/events/execution_events.py +35 -0
  24. griptape_nodes/retained_mode/events/flow_events.py +15 -2
  25. griptape_nodes/retained_mode/events/library_events.py +347 -0
  26. griptape_nodes/retained_mode/events/node_events.py +48 -0
  27. griptape_nodes/retained_mode/events/os_events.py +86 -3
  28. griptape_nodes/retained_mode/events/project_events.py +15 -1
  29. griptape_nodes/retained_mode/events/workflow_events.py +48 -1
  30. griptape_nodes/retained_mode/griptape_nodes.py +6 -2
  31. griptape_nodes/retained_mode/managers/config_manager.py +10 -8
  32. griptape_nodes/retained_mode/managers/event_manager.py +168 -0
  33. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  34. griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
  35. griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
  36. griptape_nodes/retained_mode/managers/library_manager.py +1143 -139
  37. griptape_nodes/retained_mode/managers/model_manager.py +2 -3
  38. griptape_nodes/retained_mode/managers/node_manager.py +148 -25
  39. griptape_nodes/retained_mode/managers/object_manager.py +3 -1
  40. griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
  41. griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
  42. griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
  43. griptape_nodes/retained_mode/managers/settings.py +21 -1
  44. griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
  45. griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
  46. griptape_nodes/retained_mode/retained_mode.py +3 -3
  47. griptape_nodes/traits/button.py +44 -2
  48. griptape_nodes/traits/file_system_picker.py +2 -2
  49. griptape_nodes/utils/file_utils.py +101 -0
  50. griptape_nodes/utils/git_utils.py +1226 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
  55. {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(file_path, mime_type)
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(self, file_path: Path, mime_type: str) -> tuple[bytes | str, None]:
702
- """Read file as binary, with special handling for images."""
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._handle_image_content(content, file_path, mime_type)
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 _handle_image_content(self, content: bytes, file_path: Path, mime_type: str) -> str:
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 on_write_file_request(self, request: WriteFileRequest) -> ResultPayload: # noqa: PLR0911, PLR0912, PLR0915, C901
750
- """Handle a request to write content to a file."""
751
- # Check for CREATE_NEW policy - not yet implemented
752
- if request.existing_file_policy == ExistingFilePolicy.CREATE_NEW:
753
- msg = "CREATE_NEW policy not yet implemented"
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 WriteFileResultFailure(
756
- failure_reason=FileIOFailureReason.IO_ERROR,
1423
+ return GetNextUnusedFilenameResultFailure(
1424
+ failure_reason=FileIOFailureReason.INVALID_PATH,
757
1425
  result_details=msg,
758
1426
  )
759
1427
 
760
- # Resolve file path
761
- try:
762
- file_path = self._resolve_file_path(request.file_path, workspace_only=False)
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 WriteFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
1432
+ return GetNextUnusedFilenameResultFailure(
1433
+ failure_reason=FileIOFailureReason.INVALID_PATH,
1434
+ result_details=msg,
1435
+ )
767
1436
 
768
- # Get normalized path for file operations (handles Windows long paths)
769
- normalized_path = self.normalize_path_for_platform(file_path)
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
- # Check if path is a directory (must check before attempting to write)
1440
+ # Resolve path with the index
1441
+ secrets_manager = GriptapeNodes.SecretsManager()
772
1442
  try:
773
- if Path(normalized_path).is_dir():
774
- msg = f"Path is a directory, not a file: {file_path}"
775
- logger.error(msg)
776
- return WriteFileResultFailure(failure_reason=FileIOFailureReason.IS_DIRECTORY, result_details=msg)
777
- except OSError as e:
778
- msg = f"Error checking if path is directory {file_path}: {e}"
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 WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
1453
+ return GetNextUnusedFilenameResultFailure(
1454
+ failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES,
1455
+ result_details=msg,
1456
+ )
781
1457
 
782
- # Check existing file policy (only if not appending)
783
- if not request.append and request.existing_file_policy == ExistingFilePolicy.FAIL:
784
- try:
785
- # Use os.path.exists with normalized path to handle Windows long paths
786
- if os.path.exists(normalized_path): # noqa: PTH110
787
- msg = f"File exists and existing_file_policy is FAIL: {file_path}"
788
- logger.error(msg)
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=FileIOFailureReason.POLICY_NO_OVERWRITE,
791
- result_details=msg,
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
- # Check and create parent directory if needed
799
- parent_normalized = self.normalize_path_for_platform(file_path.parent)
800
- try:
801
- if not os.path.exists(parent_normalized): # noqa: PTH110
802
- if not request.create_parents:
803
- msg = f"Parent directory does not exist and create_parents is False: {file_path.parent}"
804
- logger.error(msg)
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=FileIOFailureReason.POLICY_NO_CREATE_PARENT_DIRS,
807
- result_details=msg,
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
- # Create parent directories using os.makedirs to handle Windows long paths
811
- os.makedirs(parent_normalized, exist_ok=True) # noqa: PTH103
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"Permission denied creating parent directory {file_path.parent}: {e}"
814
- logger.error(msg)
815
- return WriteFileResultFailure(
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
- result_details=msg,
1843
+ error_message=msg,
818
1844
  )
819
- except OSError as e:
820
- msg = f"Error creating parent directory {file_path.parent}: {e}"
821
- logger.error(msg)
822
- return WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
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
- bytes_written = self._write_file_content(
827
- normalized_path, request.content, request.encoding, append=request.append
828
- )
829
- except PermissionError as e:
830
- msg = f"Permission denied writing to file {file_path}: {e}"
831
- logger.error(msg)
832
- return WriteFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
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
- msg = f"Path is a directory, not a file: {file_path}"
835
- logger.error(msg)
836
- return WriteFileResultFailure(failure_reason=FileIOFailureReason.IS_DIRECTORY, result_details=msg)
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
- msg = f"Disk full writing to file {file_path}: {e}"
845
- logger.error(msg)
846
- return WriteFileResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg)
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
- msg = f"Unexpected error writing to file {file_path}: {type(e).__name__}: {e}"
853
- logger.error(msg)
854
- return WriteFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
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 os.path.getsize(src_normalized) # noqa: PTH202
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: