From aacc0eb767ce48bf1a1ee18675c5ca24429d29a0 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Tue, 19 May 2026 16:44:19 +0100 Subject: [PATCH 01/22] feat: add directory and image sequence output to file system --- .../default_project_template.py | 20 ++ .../project_directory_parameter.py | 285 +++++++++++++++++ .../project_image_sequence_parameter.py | 299 ++++++++++++++++++ src/griptape_nodes/files/__init__.py | 22 ++ src/griptape_nodes/files/directory.py | 231 ++++++++++++++ src/griptape_nodes/files/image_sequence.py | 282 +++++++++++++++++ 6 files changed, 1139 insertions(+) create mode 100644 src/griptape_nodes/exe_types/param_components/project_directory_parameter.py create mode 100644 src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py create mode 100644 src/griptape_nodes/files/directory.py create mode 100644 src/griptape_nodes/files/image_sequence.py diff --git a/src/griptape_nodes/common/project_templates/default_project_template.py b/src/griptape_nodes/common/project_templates/default_project_template.py index 6d4a98fe9c..76649c2e2b 100644 --- a/src/griptape_nodes/common/project_templates/default_project_template.py +++ b/src/griptape_nodes/common/project_templates/default_project_template.py @@ -141,6 +141,26 @@ ), fallback="save_file", ), + "save_output_directory": SituationTemplate( + name="save_output_directory", + description="Node creates and outputs a directory (versioned with _v001, _v002, …)", + macro="{outputs}/{sub_dirs?:/}{node_name?:_}{dir_name}_v{_index:03}", + policy=SituationPolicy( + on_collision=SituationFilePolicy.CREATE_NEW, + create_dirs=True, + ), + fallback=None, + ), + "save_image_sequence_frame": SituationTemplate( + name="save_image_sequence_frame", + description="Node writes an image sequence; version in directory and frame filenames", + macro="{outputs}/{sub_dirs?:/}{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{frame:04}.{file_extension}", + policy=SituationPolicy( + on_collision=SituationFilePolicy.CREATE_NEW, + create_dirs=True, + ), + fallback=None, + ), # Workflows save into the workspace root today for backward compatibility. # Migrating to a dedicated subdirectory is tracked in # https://github.com/griptape-ai/griptape-nodes/issues/2047. diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py new file mode 100644 index 0000000000..93625442aa --- /dev/null +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -0,0 +1,285 @@ +"""ProjectDirectoryParameter - parameter component for project-aware directory creation.""" + +import logging + +from griptape_nodes.common.macro_parser import ParsedMacro +from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.files.directory import DirectoryDestination +from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY +from griptape_nodes.retained_mode.events.connection_events import ( + ListConnectionsForNodeRequest, + ListConnectionsForNodeResultSuccess, +) +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + GetSituationRequest, + GetSituationResultSuccess, + MacroPath, +) +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes +from griptape_nodes.retained_mode.retained_mode import RetainedMode +from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload +from griptape_nodes.traits.file_system_picker import FileSystemPicker + +logger = logging.getLogger("griptape_nodes") + +_FALLBACK_DIRECTORY_MACRO = "{outputs}/{node_name?:_}{dir_name}_v{_index:03}" + + +class DirectoryDestinationProvider: + """Protocol for nodes that provide a DirectoryDestination without serializing it over the wire.""" + + @property + def directory_destination(self) -> DirectoryDestination | None: ... + + +class ProjectDirectoryParameter: + """Parameter component for project-aware directory creation. + + Adds a directory name parameter to a node that, when processed, returns a + ``DirectoryDestination`` with a versioned macro path for deferred resolution. + + Usage: + # In node __init__: + self._dir_param = ProjectDirectoryParameter( + node=self, + name="output_dir", + default_dirname="renders", + ) + self._dir_param.add_parameter() + + # In node process(): + dest = self._dir_param.build_directory() + directory = dest.create() + self.set_parameter_value("output_dir", directory.location) + """ + + DEFAULT_SITUATION = "save_output_directory" + + def __init__( # noqa: PLR0913 + self, + node: BaseNode, + name: str, + *, + default_dirname: str, + situation: str = DEFAULT_SITUATION, + allowed_modes: set[ParameterMode] | None = None, + ui_options: dict | None = None, + ) -> None: + """Initialize with situation context. + + Args: + node: Parent node instance + name: Parameter name + default_dirname: Default directory name if parameter is empty + situation: Situation name (default: "save_output_directory") + allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) + ui_options: Optional UI options to pass to the generated parameter + """ + self._node = node + self._name = name + self._situation_name = situation + self._default_dirname = default_dirname + self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} + self._ui_options = ui_options + + def add_parameter(self) -> None: + """Create and add the directory name parameter to the node.""" + tooltip = f"Output directory name (uses '{self._situation_name}' situation template)" + + traits: set = { + FileSystemPicker( + allow_files=False, + allow_directories=True, + allow_create=True, + ) + } + + if ParameterMode.INPUT in self._allowed_modes: + traits.add( + Button( + icon="cog", + size="icon", + variant="secondary", + tooltip="Create and connect a DirectoryOutputSettings node", + on_click=self._on_configure_button_clicked, + ) + ) + + parameter = Parameter( + name=self._name, + type="str", + default_value=self._default_dirname, + allowed_modes=self._allowed_modes, + tooltip=tooltip, + input_types=["str"], + output_type="Directory", + traits=traits, + ui_options=self._ui_options, + ) + parameter.on_incoming_connection_removed.append(self._reset_to_default) + + self._node.add_parameter(parameter) + + def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: + """Build a DirectoryDestination from the parameter's current value. + + If an upstream node implements DirectoryDestinationProvider, its + DirectoryDestination is retrieved directly. Otherwise the parameter's + string value is used as the directory name, combined with the situation + macro. + + Args: + **extra_vars: Additional variables for the macro (e.g., sub_dirs="renders") + + Returns: + DirectoryDestination with a versioned MacroPath and baked-in policy. + + Raises: + ValueError: If an upstream DirectoryDestinationProvider returns None. + """ + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) + if isinstance(result, ListConnectionsForNodeResultSuccess): + for conn in result.incoming_connections: + if conn.target_parameter_name == self._name: + source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) + if isinstance(source_node, DirectoryDestinationProvider): + dir_dest = source_node.directory_destination + if dir_dest is None: + msg = ( + f"Attempted to build directory destination for {self._node.name}.{self._name}. " + f"Failed because upstream node '{conn.source_node_name}' provides a " + f"DirectoryDestination but returned None (likely missing a directory name)." + ) + raise ValueError(msg) + return dir_dest + + value = self._node.get_parameter_value(self._name) + dirname = value if isinstance(value, str) and value else self._default_dirname + + if "node_name" not in extra_vars: + extra_vars["node_name"] = self._node.name + + return _build_directory_destination_from_situation(dirname, self._situation_name, **extra_vars) + + def _reset_to_default( + self, + parameter: Parameter, # noqa: ARG002 + source_node_name: str, # noqa: ARG002 + source_parameter_name: str, # noqa: ARG002 + ) -> None: + self._node.set_parameter_value(self._name, self._default_dirname) + self._node.publish_update_to_parameter(self._name, self._default_dirname) + + def _on_configure_button_clicked( + self, + button: Button, # noqa: ARG002 + button_details: ButtonDetailsMessagePayload, + ) -> NodeMessageResult: + """Create and connect a DirectoryOutputSettings node to this parameter.""" + node_name = self._node.name + + has_incoming = False + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) + if isinstance(result, ListConnectionsForNodeResultSuccess): + has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) + + if has_incoming: + return NodeMessageResult( + success=False, + details=f"{node_name}: {self._name} parameter already has an incoming connection", + response=button_details, + altered_workflow_state=False, + ) + + create_result = RetainedMode.create_node_relative_to( + reference_node_name=node_name, + new_node_type="DirectoryOutputSettings", + offset_side="left", + offset_x=-750, + offset_y=0, + lock=False, + ) + + if not isinstance(create_result, str): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to create DirectoryOutputSettings node", + response=button_details, + altered_workflow_state=False, + ) + + configure_node_name = create_result + + configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) + if configure_node is not None: + configure_node.set_parameter_value("situation", self._situation_name) + configure_node.publish_update_to_parameter("situation", self._situation_name) + + current_dirname = self._node.get_parameter_value(self._name) + if isinstance(current_dirname, str) and current_dirname: + configure_node.set_parameter_value("dirname", current_dirname) + configure_node.publish_update_to_parameter("dirname", current_dirname) + + connection_result = RetainedMode.connect( + source=f"{configure_node_name}.directory_destination", + destination=f"{node_name}.{self._name}", + ) + + if not connection_result.succeeded(): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to connect {configure_node_name}.directory_destination to {self._name}", + response=button_details, + altered_workflow_state=True, + ) + + return NodeMessageResult( + success=True, + details=f"{node_name}: Created and connected {configure_node_name}", + response=button_details, + altered_workflow_state=True, + ) + + +def _build_directory_destination_from_situation( + dirname: str, + situation: str, + **extra_vars: str | int, +) -> DirectoryDestination: + """Build a DirectoryDestination from a project situation template. + + Args: + dirname: Directory name to use as the ``dir_name`` macro variable. + situation: Situation name to look up in the current project. + **extra_vars: Additional macro variables. + + Returns: + DirectoryDestination with a MacroPath and baked-in creation policy. + """ + situation_result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation)) + + if isinstance(situation_result, GetSituationResultSuccess): + situation_obj = situation_result.situation + macro_template = situation_obj.macro + on_collision = situation_obj.policy.on_collision + existing_dir_policy = SITUATION_TO_FILE_POLICY.get(on_collision, ExistingFilePolicy.CREATE_NEW) + create_parents = situation_obj.policy.create_dirs + else: + logger.error("Failed to load situation '%s', using fallback directory macro template", situation) + macro_template = _FALLBACK_DIRECTORY_MACRO + existing_dir_policy = ExistingFilePolicy.CREATE_NEW + create_parents = True + + variables: dict[str, str | int] = { + "dir_name": dirname, + **extra_vars, + } + + macro_path = MacroPath(ParsedMacro(macro_template), variables) + return DirectoryDestination( + macro_path, + existing_dir_policy=existing_dir_policy, + create_parents=create_parents, + ) diff --git a/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py new file mode 100644 index 0000000000..8cf5190995 --- /dev/null +++ b/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py @@ -0,0 +1,299 @@ +"""ProjectImageSequenceParameter - parameter component for project-aware image sequence output.""" + +import logging + +from griptape_nodes.common.macro_parser import ParsedMacro +from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.files.image_sequence import ( + ImageSequenceDestination, + build_versioned_sequence_destination, + hash_pattern_to_frame_macro, +) +from griptape_nodes.files.path_utils import FilenameParts +from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY +from griptape_nodes.retained_mode.events.connection_events import ( + ListConnectionsForNodeRequest, + ListConnectionsForNodeResultSuccess, +) +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + GetSituationRequest, + GetSituationResultSuccess, + MacroPath, +) +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes +from griptape_nodes.retained_mode.retained_mode import RetainedMode +from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload + +logger = logging.getLogger("griptape_nodes") + +_FALLBACK_SEQUENCE_MACRO = ( + "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{frame:04}.{file_extension}" +) + + +class ImageSequenceDestinationProvider: + """Protocol for nodes that provide an ImageSequenceDestination without serializing it over the wire.""" + + @property + def image_sequence_destination(self) -> ImageSequenceDestination | None: ... + + +class ProjectImageSequenceParameter: + """Parameter component for project-aware image sequence output. + + Adds a filename-pattern parameter to a node that, when processed, returns an + ``ImageSequenceDestination`` with a versioned macro path for deferred resolution. + + The parameter accepts a filename like ``"frame.exr"`` or a ``####`` pattern + like ``"frame_####.exr"``. The situation macro wraps it with versioning. + + Usage: + # In node __init__: + self._seq_param = ProjectImageSequenceParameter( + node=self, + name="output_sequence", + default_filename="frame.exr", + ) + self._seq_param.add_parameter() + + # In node process(): + dest = self._seq_param.build_sequence() + for i, frame_data in enumerate(frames): + dest.frame(i + 1).write_bytes(frame_data) + seq = dest.image_sequence + if seq is not None: + self.set_parameter_value("output_sequence", seq.location) + """ + + DEFAULT_SITUATION = "save_image_sequence_frame" + + def __init__( # noqa: PLR0913 + self, + node: BaseNode, + name: str, + *, + default_filename: str, + situation: str = DEFAULT_SITUATION, + allowed_modes: set[ParameterMode] | None = None, + ui_options: dict | None = None, + ) -> None: + """Initialize with situation context. + + Args: + node: Parent node instance + name: Parameter name + default_filename: Default filename (e.g., ``"frame.exr"`` or ``"frame_####.exr"``) + situation: Situation name (default: "save_image_sequence_frame") + allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) + ui_options: Optional UI options to pass to the generated parameter + """ + self._node = node + self._name = name + self._situation_name = situation + self._default_filename = default_filename + self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} + self._ui_options = ui_options + + def add_parameter(self) -> None: + """Create and add the image sequence pattern parameter to the node.""" + tooltip = f"Output frame filename (uses '{self._situation_name}' situation template)" + + traits: set = set() + + if ParameterMode.INPUT in self._allowed_modes: + traits.add( + Button( + icon="cog", + size="icon", + variant="secondary", + tooltip="Create and connect an ImageSequenceSettings node", + on_click=self._on_configure_button_clicked, + ) + ) + + parameter = Parameter( + name=self._name, + type="str", + default_value=self._default_filename, + allowed_modes=self._allowed_modes, + tooltip=tooltip, + input_types=["str"], + output_type="ImageSequence", + traits=traits, + ui_options=self._ui_options, + ) + parameter.on_incoming_connection_removed.append(self._reset_to_default) + + self._node.add_parameter(parameter) + + def build_sequence(self, **extra_vars: str | int) -> ImageSequenceDestination: + """Build an ImageSequenceDestination from the parameter's current value. + + If an upstream node implements ImageSequenceDestinationProvider, its + ImageSequenceDestination is retrieved directly. Otherwise the parameter's + string value (filename or #### pattern) is parsed and combined with the + situation macro. + + Args: + **extra_vars: Additional variables for the macro (e.g., sub_dirs="renders") + + Returns: + ImageSequenceDestination with a versioned MacroPath and baked-in policy. + + Raises: + ValueError: If an upstream ImageSequenceDestinationProvider returns None. + ImageSequenceError: If no available version index can be found. + """ + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) + if isinstance(result, ListConnectionsForNodeResultSuccess): + for conn in result.incoming_connections: + if conn.target_parameter_name == self._name: + source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) + if isinstance(source_node, ImageSequenceDestinationProvider): + seq_dest = source_node.image_sequence_destination + if seq_dest is None: + msg = ( + f"Attempted to build image sequence destination for {self._node.name}.{self._name}. " + f"Failed because upstream node '{conn.source_node_name}' provides an " + f"ImageSequenceDestination but returned None (likely missing a filename)." + ) + raise ValueError(msg) + return seq_dest + + value = self._node.get_parameter_value(self._name) + filename = value if isinstance(value, str) and value else self._default_filename + + if "node_name" not in extra_vars: + extra_vars["node_name"] = self._node.name + + return _build_sequence_destination_from_situation(filename, self._situation_name, **extra_vars) + + def _reset_to_default( + self, + parameter: Parameter, # noqa: ARG002 + source_node_name: str, # noqa: ARG002 + source_parameter_name: str, # noqa: ARG002 + ) -> None: + self._node.set_parameter_value(self._name, self._default_filename) + self._node.publish_update_to_parameter(self._name, self._default_filename) + + def _on_configure_button_clicked( + self, + button: Button, # noqa: ARG002 + button_details: ButtonDetailsMessagePayload, + ) -> NodeMessageResult: + """Create and connect an ImageSequenceSettings node to this parameter.""" + node_name = self._node.name + + has_incoming = False + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) + if isinstance(result, ListConnectionsForNodeResultSuccess): + has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) + + if has_incoming: + return NodeMessageResult( + success=False, + details=f"{node_name}: {self._name} parameter already has an incoming connection", + response=button_details, + altered_workflow_state=False, + ) + + create_result = RetainedMode.create_node_relative_to( + reference_node_name=node_name, + new_node_type="ImageSequenceSettings", + offset_side="left", + offset_x=-750, + offset_y=0, + lock=False, + ) + + if not isinstance(create_result, str): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to create ImageSequenceSettings node", + response=button_details, + altered_workflow_state=False, + ) + + configure_node_name = create_result + + configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) + if configure_node is not None: + configure_node.set_parameter_value("situation", self._situation_name) + configure_node.publish_update_to_parameter("situation", self._situation_name) + + current_filename = self._node.get_parameter_value(self._name) + if isinstance(current_filename, str) and current_filename: + configure_node.set_parameter_value("filename", current_filename) + configure_node.publish_update_to_parameter("filename", current_filename) + + connection_result = RetainedMode.connect( + source=f"{configure_node_name}.sequence_destination", + destination=f"{node_name}.{self._name}", + ) + + if not connection_result.succeeded(): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to connect {configure_node_name}.sequence_destination to {self._name}", + response=button_details, + altered_workflow_state=True, + ) + + return NodeMessageResult( + success=True, + details=f"{node_name}: Created and connected {configure_node_name}", + response=button_details, + altered_workflow_state=True, + ) + + +def _build_sequence_destination_from_situation( + filename: str, + situation: str, + **extra_vars: str | int, +) -> ImageSequenceDestination: + """Build an ImageSequenceDestination from a project situation template. + + Parses the filename (or #### pattern) into parts, looks up the situation, + and builds a versioned destination by finding the first available ``_index``. + + Args: + filename: Filename or #### pattern (e.g., ``"frame.exr"`` or ``"frame_####.exr"``). + situation: Situation name to look up in the current project. + **extra_vars: Additional macro variables. + + Returns: + ImageSequenceDestination with a locked version index. + """ + situation_result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation)) + + if isinstance(situation_result, GetSituationResultSuccess): + situation_obj = situation_result.situation + macro_template = situation_obj.macro + on_collision = situation_obj.policy.on_collision + existing_file_policy = SITUATION_TO_FILE_POLICY.get(on_collision, ExistingFilePolicy.OVERWRITE) + create_parents = situation_obj.policy.create_dirs + else: + logger.error("Failed to load situation '%s', using fallback sequence macro template", situation) + macro_template = _FALLBACK_SEQUENCE_MACRO + existing_file_policy = ExistingFilePolicy.OVERWRITE + create_parents = True + + normalized_filename = hash_pattern_to_frame_macro(filename) + parts = FilenameParts.from_filename(normalized_filename) + + variables: dict[str, str | int] = { + "file_name_base": parts.stem, + "file_extension": parts.extension, + **extra_vars, + } + + macro_path = MacroPath(ParsedMacro(macro_template), variables) + return build_versioned_sequence_destination( + macro_path, + existing_file_policy=existing_file_policy, + create_parents=create_parents, + ) diff --git a/src/griptape_nodes/files/__init__.py b/src/griptape_nodes/files/__init__.py index c66eb736d5..533ae4d678 100644 --- a/src/griptape_nodes/files/__init__.py +++ b/src/griptape_nodes/files/__init__.py @@ -2,18 +2,40 @@ To use the File loader API: from griptape_nodes.files.file import File, FileContent, FileDestination, FileLoadError, FileWriteError + +To use the Directory API: + from griptape_nodes.files.directory import Directory, DirectoryDestination, DirectoryError + +To use the ImageSequence API: + from griptape_nodes.files.image_sequence import ImageSequence, ImageSequenceDestination, ImageSequenceError """ from griptape_nodes.files.base_file_driver import BaseFileDriver +from griptape_nodes.files.directory import Directory, DirectoryDestination, DirectoryError from griptape_nodes.files.file_driver import FileDriver from griptape_nodes.files.file_driver_registry import ( FileDriverNotFoundError, FileDriverRegistry, ) +from griptape_nodes.files.image_sequence import ( + ImageSequence, + ImageSequenceDestination, + ImageSequenceError, + frame_macro_to_hash_pattern, + hash_pattern_to_frame_macro, +) __all__ = [ "BaseFileDriver", + "Directory", + "DirectoryDestination", + "DirectoryError", "FileDriver", "FileDriverNotFoundError", "FileDriverRegistry", + "ImageSequence", + "ImageSequenceDestination", + "ImageSequenceError", + "frame_macro_to_hash_pattern", + "hash_pattern_to_frame_macro", ] diff --git a/src/griptape_nodes/files/directory.py b/src/griptape_nodes/files/directory.py new file mode 100644 index 0000000000..4f8d0632ac --- /dev/null +++ b/src/griptape_nodes/files/directory.py @@ -0,0 +1,231 @@ +"""Directory - path-like object for directory operations via the retained mode API.""" + +from __future__ import annotations + +from pathlib import Path + +from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + AttemptMapAbsolutePathToProjectRequest, + AttemptMapAbsolutePathToProjectResultSuccess, + GetPathForMacroRequest, + GetPathForMacroResultSuccess, + MacroPath, +) +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes + +_MAX_VERSION_INDEX = 9999 + + +class DirectoryError(Exception): + """Raised when a directory operation fails.""" + + def __init__(self, result_details: str) -> None: + self.result_details = result_details + super().__init__(result_details) + + +class Directory: + """Path-like object representing a directory. + + The constructor stores a directory reference without performing any I/O. + Call ``resolve()`` to get the absolute filesystem path. + + Supports MacroPath resolution: pass a MacroPath (which contains variables) + or a plain string path. Plain strings containing macro variables are + automatically wrapped in a MacroPath. + """ + + def __init__(self, dir_path: str | MacroPath) -> None: + """Store directory reference. No I/O is performed. + + Args: + dir_path: Path to the directory. Can be a plain string or a MacroPath. + """ + if isinstance(dir_path, str): + try: + parsed = ParsedMacro(dir_path) + except MacroSyntaxError: + self._dir_path: str | MacroPath = dir_path + else: + if parsed.get_variables(): + self._dir_path = MacroPath(parsed, {}) + else: + self._dir_path = dir_path + else: + self._dir_path = dir_path + + def resolve(self) -> Path: + """Resolve and return the absolute path for this directory. + + Returns: + Absolute Path object. + + Raises: + DirectoryError: If macro resolution fails (e.g. no project loaded). + """ + return Path(_resolve_dir_path(self._dir_path)) + + @property + def location(self) -> str: + """Return the most portable string representation of this directory's location. + + Returns the macro template when the directory holds a macro path, + otherwise the plain path string. No I/O is performed. + """ + if isinstance(self._dir_path, MacroPath): + return self._dir_path.parsed_macro.template + return self._dir_path + + @property + def name(self) -> str: + """Return the directory name (last path component).""" + return Path(self.location).name + + +class DirectoryDestination: + """A pre-configured handle for directory creation. + + Bundles a directory path with a creation policy so it can be passed + around as a self-contained object. The consumer calls ``create()`` + without needing to know the policy details. + + When the policy is CREATE_NEW and the path contains a ``{_index}`` + macro variable, ``create()`` increments ``_index`` starting at 1 until + it finds a path that does not yet exist, then creates that directory. + This produces versioned directories: ``renders_v001/``, ``renders_v002/``. + """ + + def __init__( + self, + dir_path: str | MacroPath, + *, + existing_dir_policy: ExistingFilePolicy = ExistingFilePolicy.CREATE_NEW, + create_parents: bool = True, + ) -> None: + """Store directory path and creation configuration. No I/O is performed. + + Args: + dir_path: Path to the directory. Can be a plain string or a MacroPath. + existing_dir_policy: How to handle an existing directory. + CREATE_NEW increments _index; OVERWRITE allows reuse; FAIL raises. + Defaults to CREATE_NEW. + create_parents: If True, create intermediate directories automatically. + Defaults to True. + """ + self._dir_path = dir_path + self._existing_dir_policy = existing_dir_policy + self._create_parents = create_parents + + def resolve(self) -> Path: + """Resolve and return the absolute path for this destination. + + Returns: + Absolute Path object. + + Raises: + DirectoryError: If macro resolution fails. + """ + return Path(_resolve_dir_path(self._dir_path)) + + @property + def location(self) -> str: + """Return the most portable string representation of this destination's location.""" + if isinstance(self._dir_path, MacroPath): + return self._dir_path.parsed_macro.template + return self._dir_path + + def create(self) -> Directory: + """Create the directory and return a Directory referencing it. + + When policy is CREATE_NEW and the path is a MacroPath containing + ``{_index}``, increments the index starting at 1 until a non-existent + directory is found, then creates it. + + Returns: + Directory referencing the created path (in macro form if inside project). + + Raises: + DirectoryError: If the directory cannot be created. + """ + if self._existing_dir_policy == ExistingFilePolicy.CREATE_NEW and isinstance(self._dir_path, MacroPath): + return self._create_with_versioning() + return self._create_direct() + + def _create_with_versioning(self) -> Directory: + """Increment _index until a non-existent directory is found, then create it.""" + macro_path: MacroPath = self._dir_path # type: ignore[assignment] + + for index in range(1, _MAX_VERSION_INDEX + 1): + variables = {**macro_path.variables, "_index": index} + resolve_result = GriptapeNodes.handle_request( + GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=variables) + ) + + if not isinstance(resolve_result, GetPathForMacroResultSuccess): + msg = ( + f"Attempted to create versioned directory. Failed to resolve macro: {resolve_result.result_details}" + ) + raise DirectoryError(msg) + + absolute_path = resolve_result.absolute_path + if not absolute_path.exists(): + absolute_path.mkdir(parents=self._create_parents, exist_ok=False) + locked_macro = MacroPath(macro_path.parsed_macro, variables) + return _map_to_macro_directory(absolute_path, locked_macro) + + msg = f"Attempted to create versioned directory. Failed because no available path found after {_MAX_VERSION_INDEX} attempts." + raise DirectoryError(msg) + + def _create_direct(self) -> Directory: + """Create the directory without versioning.""" + resolved = Path(_resolve_dir_path(self._dir_path)) + + if resolved.exists() and self._existing_dir_policy == ExistingFilePolicy.FAIL: + msg = f"Attempted to create directory. Failed because directory already exists: {resolved}" + raise DirectoryError(msg) + + resolved.mkdir(parents=self._create_parents, exist_ok=True) + return _map_to_macro_directory(resolved, self._dir_path) + + +def _resolve_dir_path(dir_path: str | MacroPath) -> str: + """Resolve a directory path, handling MacroPath resolution if needed. + + Args: + dir_path: A plain path string or a MacroPath. + + Returns: + A resolved path string. + + Raises: + DirectoryError: If macro resolution fails. + """ + if isinstance(dir_path, str): + return dir_path + + resolve_result = GriptapeNodes.handle_request( + GetPathForMacroRequest(parsed_macro=dir_path.parsed_macro, variables=dir_path.variables) + ) + + if not isinstance(resolve_result, GetPathForMacroResultSuccess): + msg = f"Attempted to resolve directory path. Failed: {resolve_result.result_details}" + raise DirectoryError(msg) + + return str(resolve_result.absolute_path) + + +def _map_to_macro_directory(absolute_path: Path, fallback_path: str | MacroPath) -> Directory: + """Attempt to map the created directory path to a portable macro form. + + Returns a Directory holding the macro template when the path is inside + a project directory, so callers can store a portable reference. + Falls back to the locked MacroPath or absolute path string if mapping fails. + """ + map_result = GriptapeNodes.handle_request(AttemptMapAbsolutePathToProjectRequest(absolute_path=absolute_path)) + if isinstance(map_result, AttemptMapAbsolutePathToProjectResultSuccess) and map_result.mapped_path is not None: + return Directory(map_result.mapped_path) + if isinstance(fallback_path, MacroPath): + return Directory(fallback_path) + return Directory(str(absolute_path)) diff --git a/src/griptape_nodes/files/image_sequence.py b/src/griptape_nodes/files/image_sequence.py new file mode 100644 index 0000000000..bccb6578ba --- /dev/null +++ b/src/griptape_nodes/files/image_sequence.py @@ -0,0 +1,282 @@ +"""ImageSequence - a collection of images identified by a frame-number pattern.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +from griptape_nodes.files.directory import Directory +from griptape_nodes.files.file import File, FileDestination +from griptape_nodes.files.project_file import ProjectFileDestination +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + GetPathForMacroRequest, + GetPathForMacroResultSuccess, + MacroPath, +) +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes + +if TYPE_CHECKING: + from collections.abc import Callable + +_FRAME_VAR_NAME = "frame" +_HASH_PATTERN = re.compile(r"#+") +_FRAME_MACRO_PATTERN = re.compile(r"\{frame(?::(\d+))?\}") +_MAX_VERSION_INDEX = 9999 + + +class ImageSequenceError(Exception): + """Raised when an image sequence operation fails.""" + + def __init__(self, result_details: str) -> None: + self.result_details = result_details + super().__init__(result_details) + + +class ImageSequence: + """A collection of images identified by a frame-number pattern. + + Internally stores a MacroPath template with a ``{frame:04}`` variable slot. + Exposes the industry-standard ``####`` notation at property boundaries for + DCC software interop. + + Use ``frame(n)`` to get a ``File`` for reading a specific frame, and + ``directory`` to get the containing folder. + """ + + def __init__(self, frame_macro: MacroPath) -> None: + """Store the frame macro. No I/O is performed. + + Args: + frame_macro: MacroPath whose template contains a ``{frame}`` variable + slot and whose variables dict holds all resolved values (including + a locked ``_index`` when versioning is in effect). + """ + self._frame_macro = frame_macro + + @property + def location(self) -> str: + """Return the raw macro template for wire serialization. + + Example: ``"{outputs}/Render_frames_v001/{frame:04}.exr"`` + """ + return self._frame_macro.parsed_macro.template + + @property + def pattern(self) -> str: + """Return the #### notation form of this sequence's frame pattern. + + The ``{frame:NN}`` macro variable is replaced with a run of ``#`` + characters matching the padding width. + + Example: ``"{outputs}/renders_v001/frame_####.exr"`` + """ + return frame_macro_to_hash_pattern(self.location) + + @property + def directory(self) -> Directory: + """Return the containing directory as a Directory. + + No I/O is performed; the directory path is derived from the macro + template by stripping the filename component. + """ + dir_location = str(Path(self.location).parent) + return Directory(dir_location) + + def frame(self, frame_number: int) -> File: + """Return a File for reading a specific frame. + + Args: + frame_number: Frame index (caller's convention, e.g. 0-based or 1-based). + + Returns: + File that resolves to the absolute path of that frame. + """ + variables = {**self._frame_macro.variables, _FRAME_VAR_NAME: frame_number} + return File(MacroPath(self._frame_macro.parsed_macro, variables)) + + +class _FrameWriteDestination(ProjectFileDestination): + """FileDestination subclass that fires a callback after each successful write.""" + + def __init__( + self, + frame_path: MacroPath, + *, + existing_file_policy: ExistingFilePolicy, + create_parents: bool, + on_written: Callable[[File], None], + ) -> None: + super().__init__( + frame_path, + existing_file_policy=existing_file_policy, + create_parents=create_parents, + ) + self._on_written = on_written + + def write_bytes(self, content: bytes) -> File: + result = super().write_bytes(content) + self._on_written(result) + return result + + async def awrite_bytes(self, content: bytes) -> File: + result = await super().awrite_bytes(content) + self._on_written(result) + return result + + def write_text(self, content: str, encoding: str = "utf-8") -> File: + result = super().write_text(content, encoding) + self._on_written(result) + return result + + async def awrite_text(self, content: str, encoding: str = "utf-8") -> File: + result = await super().awrite_text(content, encoding) + self._on_written(result) + return result + + +class ImageSequenceDestination: + """A pre-configured write handle for an image sequence. + + Bundles a frame macro path and write policy. The caller resolves a + version index once (via ``build_versioned_sequence_destination``), then + calls ``frame(n)`` to get a ``FileDestination`` for each frame. + + The ``image_sequence`` property becomes non-None after the first frame write. + """ + + def __init__( + self, + frame_macro: MacroPath, + *, + existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE, + create_parents: bool = True, + ) -> None: + """Store frame macro and write configuration. No I/O is performed. + + Args: + frame_macro: MacroPath with template containing a ``{frame}`` variable. + Should already have ``_index`` locked in the variables dict. + existing_file_policy: How to handle existing frame files. Defaults to OVERWRITE. + create_parents: If True, create parent directories automatically. Defaults to True. + """ + self._frame_macro = frame_macro + self._existing_file_policy = existing_file_policy + self._create_parents = create_parents + self._written_sequence: ImageSequence | None = None + + @property + def image_sequence(self) -> ImageSequence | None: + """Return the ImageSequence descriptor after at least one frame has been written. + + Returns None before any frame write. + """ + return self._written_sequence + + def frame(self, frame_number: int) -> FileDestination: + """Return a FileDestination for writing a specific frame. + + After the returned destination is used to write, the ``image_sequence`` + property becomes available. + + Args: + frame_number: Frame index to write. + + Returns: + FileDestination pre-configured with the resolved frame path and policy. + """ + variables = {**self._frame_macro.variables, _FRAME_VAR_NAME: frame_number} + frame_path = MacroPath(self._frame_macro.parsed_macro, variables) + return _FrameWriteDestination( + frame_path, + existing_file_policy=self._existing_file_policy, + create_parents=self._create_parents, + on_written=self._on_frame_written, + ) + + def _on_frame_written(self, written_file: File) -> None: # noqa: ARG002 + """Record that a frame was written to expose the ImageSequence descriptor.""" + if self._written_sequence is None: + self._written_sequence = ImageSequence(self._frame_macro) + + +def build_versioned_sequence_destination( + frame_macro: MacroPath, + *, + existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE, + create_parents: bool = True, +) -> ImageSequenceDestination: + """Find the first available version index and return a locked ImageSequenceDestination. + + Increments ``_index`` in the frame macro starting at 1 until the corresponding + parent directory does not exist. Returns an ``ImageSequenceDestination`` with + that index locked into the variables dict. + + Args: + frame_macro: MacroPath template with ``{frame}`` and ``{_index}`` variables. + existing_file_policy: Policy for individual frame files. Defaults to OVERWRITE. + create_parents: Whether to create parent directories. Defaults to True. + + Returns: + ImageSequenceDestination with a locked _index version. + + Raises: + ImageSequenceError: If no available version is found within the limit. + """ + for index in range(1, _MAX_VERSION_INDEX + 1): + probe_variables = {**frame_macro.variables, "_index": index, _FRAME_VAR_NAME: 0} + resolve_result = GriptapeNodes.handle_request( + GetPathForMacroRequest(parsed_macro=frame_macro.parsed_macro, variables=probe_variables) + ) + if not isinstance(resolve_result, GetPathForMacroResultSuccess): + msg = f"Attempted to find available sequence version. Failed to resolve macro: {resolve_result.result_details}" + raise ImageSequenceError(msg) + + parent_dir = resolve_result.absolute_path.parent + if not parent_dir.exists(): + locked_vars = {**frame_macro.variables, "_index": index} + locked_macro = MacroPath(frame_macro.parsed_macro, locked_vars) + return ImageSequenceDestination( + locked_macro, + existing_file_policy=existing_file_policy, + create_parents=create_parents, + ) + + msg = f"Attempted to find available sequence version. Failed because no path found after {_MAX_VERSION_INDEX} attempts." + raise ImageSequenceError(msg) + + +def hash_pattern_to_frame_macro(pattern: str) -> str: + """Convert a #### frame pattern to a macro template with {frame:NN} syntax. + + Args: + pattern: Pattern string like ``"render_####.exr"`` or ``"frame_##.png"``. + + Returns: + Macro template string like ``"render_{frame:04}.exr"``. + """ + + def replace_hashes(match: re.Match) -> str: + width = len(match.group()) + return f"{{frame:{width:02d}}}" + + return _HASH_PATTERN.sub(replace_hashes, pattern) + + +def frame_macro_to_hash_pattern(template: str) -> str: + """Convert a macro template with {frame:NN} syntax to a #### pattern. + + Args: + template: Macro template like ``"render_{frame:04}.exr"``. + + Returns: + Pattern string like ``"render_####.exr"``. + """ + + def replace_frame_var(match: re.Match) -> str: + width_str = match.group(1) + width = int(width_str) if width_str else 4 + return "#" * width + + return _FRAME_MACRO_PATTERN.sub(replace_frame_var, template) From 87e15597108272b87dfda13a7a72a9346b9ebf0b Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Wed, 20 May 2026 15:49:34 +0100 Subject: [PATCH 02/22] test: unit tests for directory and imgsequence output types --- src/griptape_nodes/files/__init__.py | 16 - .../test_project_directory_parameter.py | 157 ++++++++ .../test_project_image_sequence_parameter.py | 197 ++++++++++ tests/unit/files/test_directory.py | 278 ++++++++++++++ tests/unit/files/test_image_sequence.py | 356 ++++++++++++++++++ 5 files changed, 988 insertions(+), 16 deletions(-) create mode 100644 tests/unit/exe_types/param_components/test_project_directory_parameter.py create mode 100644 tests/unit/exe_types/param_components/test_project_image_sequence_parameter.py create mode 100644 tests/unit/files/test_directory.py create mode 100644 tests/unit/files/test_image_sequence.py diff --git a/src/griptape_nodes/files/__init__.py b/src/griptape_nodes/files/__init__.py index 533ae4d678..00b94059c3 100644 --- a/src/griptape_nodes/files/__init__.py +++ b/src/griptape_nodes/files/__init__.py @@ -11,31 +11,15 @@ """ from griptape_nodes.files.base_file_driver import BaseFileDriver -from griptape_nodes.files.directory import Directory, DirectoryDestination, DirectoryError from griptape_nodes.files.file_driver import FileDriver from griptape_nodes.files.file_driver_registry import ( FileDriverNotFoundError, FileDriverRegistry, ) -from griptape_nodes.files.image_sequence import ( - ImageSequence, - ImageSequenceDestination, - ImageSequenceError, - frame_macro_to_hash_pattern, - hash_pattern_to_frame_macro, -) __all__ = [ "BaseFileDriver", - "Directory", - "DirectoryDestination", - "DirectoryError", "FileDriver", "FileDriverNotFoundError", "FileDriverRegistry", - "ImageSequence", - "ImageSequenceDestination", - "ImageSequenceError", - "frame_macro_to_hash_pattern", - "hash_pattern_to_frame_macro", ] diff --git a/tests/unit/exe_types/param_components/test_project_directory_parameter.py b/tests/unit/exe_types/param_components/test_project_directory_parameter.py new file mode 100644 index 0000000000..46214ca873 --- /dev/null +++ b/tests/unit/exe_types/param_components/test_project_directory_parameter.py @@ -0,0 +1,157 @@ +"""Unit tests for _build_directory_destination_from_situation.""" + +from unittest.mock import patch + +from griptape_nodes.common.project_templates.situation import ( + SituationFilePolicy, + SituationPolicy, + SituationTemplate, +) +from griptape_nodes.exe_types.param_components.project_directory_parameter import ( + _FALLBACK_DIRECTORY_MACRO, + _build_directory_destination_from_situation, +) +from griptape_nodes.files.directory import DirectoryDestination +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + GetSituationResultFailure, + GetSituationResultSuccess, + MacroPath, +) + +HANDLE_REQUEST_PATH = ( + "griptape_nodes.exe_types.param_components.project_directory_parameter.GriptapeNodes.handle_request" +) + +_POLICY_MAP = { + "CREATE_NEW": SituationFilePolicy.CREATE_NEW, + "OVERWRITE": SituationFilePolicy.OVERWRITE, + "FAIL": SituationFilePolicy.FAIL, +} + + +def _make_situation( + macro: str, + on_collision: str = "CREATE_NEW", + *, + create_dirs: bool = True, +) -> SituationTemplate: + return SituationTemplate( + name="test_situation", + macro=macro, + policy=SituationPolicy(on_collision=_POLICY_MAP[on_collision], create_dirs=create_dirs), + ) + + +class TestBuildDirectoryDestinationFromSituation: + """Tests for _build_directory_destination_from_situation helper.""" + + def test_uses_situation_macro(self) -> None: + situation = _make_situation("{outputs}/{node_name}/{dir_name}_v{_index:03}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("renders", "save_output_directory") + + assert isinstance(dest, DirectoryDestination) + assert isinstance(dest._dir_path, MacroPath) + assert dest._dir_path.parsed_macro.template == "{outputs}/{node_name}/{dir_name}_v{_index:03}" + + def test_falls_back_to_default_macro_when_situation_not_found(self) -> None: + failure = GetSituationResultFailure(result_details="not found") + + with patch(HANDLE_REQUEST_PATH, return_value=failure): + dest = _build_directory_destination_from_situation("renders", "missing_situation") + + assert isinstance(dest._dir_path, MacroPath) + assert dest._dir_path.parsed_macro.template == _FALLBACK_DIRECTORY_MACRO + + def test_wires_dirname_as_macro_variable(self) -> None: + situation = _make_situation("{outputs}/{dir_name}_v{_index:03}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("frames", "save_output_directory") + + assert isinstance(dest._dir_path, MacroPath) + assert dest._dir_path.variables["dir_name"] == "frames" + + def test_extra_vars_forwarded_to_macro(self) -> None: + situation = _make_situation("{outputs}/{node_name}/{dir_name}_v{_index:03}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("renders", "save_output_directory", node_name="MyNode") + + assert isinstance(dest._dir_path, MacroPath) + assert dest._dir_path.variables["node_name"] == "MyNode" + + def test_situation_overwrite_policy_maps_to_overwrite(self) -> None: + situation = _make_situation("{outputs}/{dir_name}", on_collision="OVERWRITE") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("renders", "save_output_directory") + + assert dest._existing_dir_policy == ExistingFilePolicy.OVERWRITE + + def test_situation_create_new_policy_maps_to_create_new(self) -> None: + situation = _make_situation("{outputs}/{dir_name}", on_collision="CREATE_NEW") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("renders", "save_output_directory") + + assert dest._existing_dir_policy == ExistingFilePolicy.CREATE_NEW + + def test_situation_fail_policy_maps_to_fail(self) -> None: + situation = _make_situation("{outputs}/{dir_name}", on_collision="FAIL") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("renders", "save_output_directory") + + assert dest._existing_dir_policy == ExistingFilePolicy.FAIL + + def test_situation_create_dirs_false_propagated(self) -> None: + situation = _make_situation("{outputs}/{dir_name}", create_dirs=False) + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("renders", "save_output_directory") + + assert dest._create_parents is False + + def test_fallback_uses_create_new_policy(self) -> None: + failure = GetSituationResultFailure(result_details="not found") + + with patch(HANDLE_REQUEST_PATH, return_value=failure): + dest = _build_directory_destination_from_situation("renders", "missing_situation") + + assert dest._existing_dir_policy == ExistingFilePolicy.CREATE_NEW + + def test_multiple_extra_vars_all_forwarded(self) -> None: + situation = _make_situation("{outputs}/{dir_name}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation( + "renders", + "save_output_directory", + node_name="MyNode", + sub_dirs="pass_1", + ) + + assert isinstance(dest._dir_path, MacroPath) + assert dest._dir_path.variables["node_name"] == "MyNode" + assert dest._dir_path.variables["sub_dirs"] == "pass_1" + assert dest._dir_path.variables["dir_name"] == "renders" + + def test_returns_directory_destination(self) -> None: + situation = _make_situation("{outputs}/{dir_name}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + + with patch(HANDLE_REQUEST_PATH, return_value=success): + dest = _build_directory_destination_from_situation("renders", "save_output_directory") + + assert isinstance(dest, DirectoryDestination) diff --git a/tests/unit/exe_types/param_components/test_project_image_sequence_parameter.py b/tests/unit/exe_types/param_components/test_project_image_sequence_parameter.py new file mode 100644 index 0000000000..96da23ce60 --- /dev/null +++ b/tests/unit/exe_types/param_components/test_project_image_sequence_parameter.py @@ -0,0 +1,197 @@ +"""Unit tests for _build_sequence_destination_from_situation.""" + +from unittest.mock import MagicMock, patch + +from griptape_nodes.common.project_templates.situation import ( + SituationFilePolicy, + SituationPolicy, + SituationTemplate, +) +from griptape_nodes.exe_types.param_components.project_image_sequence_parameter import ( + _FALLBACK_SEQUENCE_MACRO, + _build_sequence_destination_from_situation, +) +from griptape_nodes.files.image_sequence import ImageSequenceDestination +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + GetSituationResultFailure, + GetSituationResultSuccess, +) + +HANDLE_REQUEST_PATH = ( + "griptape_nodes.exe_types.param_components.project_image_sequence_parameter.GriptapeNodes.handle_request" +) +BUILD_VERSIONED_PATH = ( + "griptape_nodes.exe_types.param_components.project_image_sequence_parameter.build_versioned_sequence_destination" +) + +_POLICY_MAP = { + "CREATE_NEW": SituationFilePolicy.CREATE_NEW, + "OVERWRITE": SituationFilePolicy.OVERWRITE, + "FAIL": SituationFilePolicy.FAIL, +} + + +def _make_situation( + macro: str, + on_collision: str = "OVERWRITE", + *, + create_dirs: bool = True, +) -> SituationTemplate: + return SituationTemplate( + name="test_situation", + macro=macro, + policy=SituationPolicy(on_collision=_POLICY_MAP[on_collision], create_dirs=create_dirs), + ) + + +class TestBuildSequenceDestinationFromSituation: + """Tests for _build_sequence_destination_from_situation helper.""" + + def test_uses_situation_macro(self) -> None: + situation_macro = ( + "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_{frame:04}.{file_extension}" + ) + situation = _make_situation(situation_macro) + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + + call_args = mock_build.call_args + macro_path = call_args.args[0] + assert macro_path.parsed_macro.template == situation_macro + + def test_falls_back_to_default_macro_when_situation_not_found(self) -> None: + failure = GetSituationResultFailure(result_details="not found") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=failure), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame.exr", "missing_situation") + + call_args = mock_build.call_args + macro_path = call_args.args[0] + assert macro_path.parsed_macro.template == _FALLBACK_SEQUENCE_MACRO + + def test_plain_filename_parsed_into_stem_and_extension(self) -> None: + situation = _make_situation("{outputs}/{file_name_base}_{frame:04}.{file_extension}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + + macro_path = mock_build.call_args.args[0] + assert macro_path.variables["file_name_base"] == "frame" + assert macro_path.variables["file_extension"] == "exr" + + def test_hash_pattern_filename_converted_before_parsing(self) -> None: + situation = _make_situation("{outputs}/{file_name_base}_{frame:04}.{file_extension}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame_####.exr", "save_image_sequence_frame") + + macro_path = mock_build.call_args.args[0] + assert macro_path.variables["file_extension"] == "exr" + + def test_extra_vars_forwarded_to_macro(self) -> None: + situation = _make_situation("{outputs}/{node_name}/{file_name_base}_{frame:04}.{file_extension}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame", node_name="MyNode") + + macro_path = mock_build.call_args.args[0] + assert macro_path.variables["node_name"] == "MyNode" + + def test_situation_overwrite_policy_forwarded(self) -> None: + situation = _make_situation("{outputs}/{frame:04}.exr", on_collision="OVERWRITE") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + + call_kwargs = mock_build.call_args.kwargs + assert call_kwargs["existing_file_policy"] == ExistingFilePolicy.OVERWRITE + + def test_situation_create_dirs_forwarded(self) -> None: + situation = _make_situation("{outputs}/{frame:04}.exr", create_dirs=False) + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + + call_kwargs = mock_build.call_args.kwargs + assert call_kwargs["create_parents"] is False + + def test_fallback_uses_overwrite_policy(self) -> None: + failure = GetSituationResultFailure(result_details="not found") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=failure), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation("frame.exr", "missing_situation") + + call_kwargs = mock_build.call_args.kwargs + assert call_kwargs["existing_file_policy"] == ExistingFilePolicy.OVERWRITE + + def test_returns_image_sequence_destination(self) -> None: + situation = _make_situation("{outputs}/{frame:04}.exr") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest): + result = _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + + assert result is mock_dest + + def test_multiple_extra_vars_all_forwarded(self) -> None: + situation = _make_situation("{outputs}/{file_name_base}_{frame:04}.{file_extension}") + success = GetSituationResultSuccess(situation=situation, result_details="ok") + mock_dest = MagicMock(spec=ImageSequenceDestination) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + ): + _build_sequence_destination_from_situation( + "render.exr", + "save_image_sequence_frame", + node_name="Renderer", + sub_dirs="pass_1", + ) + + macro_path = mock_build.call_args.args[0] + assert macro_path.variables["node_name"] == "Renderer" + assert macro_path.variables["sub_dirs"] == "pass_1" + assert macro_path.variables["file_name_base"] == "render" + assert macro_path.variables["file_extension"] == "exr" diff --git a/tests/unit/files/test_directory.py b/tests/unit/files/test_directory.py new file mode 100644 index 0000000000..f63a69dc75 --- /dev/null +++ b/tests/unit/files/test_directory.py @@ -0,0 +1,278 @@ +"""Unit tests for Directory and DirectoryDestination.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro +from griptape_nodes.files.directory import Directory, DirectoryDestination, DirectoryError +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + AttemptMapAbsolutePathToProjectResultSuccess, + GetPathForMacroResultFailure, + GetPathForMacroResultSuccess, + MacroPath, + PathResolutionFailureReason, +) + +HANDLE_REQUEST_PATH = "griptape_nodes.files.directory.GriptapeNodes.handle_request" + + +class TestDirectoryConstructor: + """Tests that Directory constructor stores references without I/O.""" + + def test_stores_plain_string(self) -> None: + d = Directory("workspace/renders") + assert d._dir_path == "workspace/renders" + + def test_does_no_io(self) -> None: + with patch(HANDLE_REQUEST_PATH) as mock_handle: + Directory("workspace/renders") + mock_handle.assert_not_called() + + def test_auto_wraps_macro_string_in_macro_path(self) -> None: + d = Directory("{outputs}/frames") + assert isinstance(d._dir_path, MacroPath) + assert d._dir_path.variables == {} + + def test_keeps_plain_string_without_vars_unchanged(self) -> None: + d = Directory("workspace/frames") + assert d._dir_path == "workspace/frames" + + def test_stores_macro_path_unchanged(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {"outputs": "/resolved"}) + d = Directory(macro_path) + assert d._dir_path is macro_path + + def test_invalid_macro_syntax_stored_as_plain_string(self) -> None: + with patch("griptape_nodes.files.directory.ParsedMacro", side_effect=MacroSyntaxError("bad")): + d = Directory("{unclosed") + assert d._dir_path == "{unclosed" + + def test_macro_string_preserves_template(self) -> None: + d = Directory("{outputs}/renders_v001") + assert isinstance(d._dir_path, MacroPath) + assert d._dir_path.parsed_macro.template == "{outputs}/renders_v001" + + +class TestDirectoryResolve: + """Tests for Directory.resolve().""" + + def test_resolve_plain_string_returns_path(self, tmp_path: Path) -> None: + dir_path = str(tmp_path / "renders") + d = Directory(dir_path) + with patch(HANDLE_REQUEST_PATH) as mock_handle: + result = d.resolve() + mock_handle.assert_not_called() + assert result == Path(dir_path) + + def test_resolve_macro_path_calls_handle_request(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {"outputs": "/workspace/outputs"}) + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("outputs/renders"), + absolute_path=Path("/workspace/outputs/renders"), + ) + with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + result = Directory(macro_path).resolve() + assert result == Path("/workspace/outputs/renders") + + def test_resolve_macro_path_failure_raises_directory_error(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {}) + failure = GetPathForMacroResultFailure( + result_details="Missing variables: outputs", + failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, + missing_variables={"outputs"}, + ) + with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(DirectoryError): + Directory(macro_path).resolve() + + +class TestDirectoryLocation: + """Tests for Directory.location property.""" + + def test_location_plain_string(self) -> None: + d = Directory("workspace/renders") + assert d.location == "workspace/renders" + + def test_location_macro_path_returns_template(self) -> None: + d = Directory("{outputs}/renders") + assert d.location == "{outputs}/renders" + + def test_location_macro_path_object_returns_template(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {"outputs": "/resolved"}) + d = Directory(macro_path) + assert d.location == "{outputs}/renders" + + def test_location_no_io_performed(self) -> None: + with patch(HANDLE_REQUEST_PATH) as mock_handle: + d = Directory("{outputs}/renders") + _ = d.location + mock_handle.assert_not_called() + + +class TestDirectoryName: + """Tests for Directory.name property.""" + + def test_name_plain_string(self) -> None: + d = Directory("workspace/renders") + assert d.name == "renders" + + def test_name_macro_template(self) -> None: + d = Directory("{outputs}/renders_v001") + assert d.name == "renders_v001" + + def test_name_nested_path(self) -> None: + d = Directory("workspace/project/outputs/frames") + assert d.name == "frames" + + +class TestDirectoryDestinationConstructor: + """Tests for DirectoryDestination constructor.""" + + def test_does_no_io(self) -> None: + with patch(HANDLE_REQUEST_PATH) as mock_handle: + DirectoryDestination("workspace/renders") + mock_handle.assert_not_called() + + def test_defaults_create_new_and_create_parents(self) -> None: + dest = DirectoryDestination("workspace/renders") + assert dest._existing_dir_policy == ExistingFilePolicy.CREATE_NEW + assert dest._create_parents is True + + def test_stores_overwrite_policy(self) -> None: + dest = DirectoryDestination("workspace/renders", existing_dir_policy=ExistingFilePolicy.OVERWRITE) + assert dest._existing_dir_policy == ExistingFilePolicy.OVERWRITE + + def test_stores_create_parents_false(self) -> None: + dest = DirectoryDestination("workspace/renders", create_parents=False) + assert dest._create_parents is False + + +class TestDirectoryDestinationCreateDirect: + """Tests for DirectoryDestination.create() in non-versioning (direct) mode.""" + + def test_create_plain_string_creates_directory(self, tmp_path: Path) -> None: + dir_path = str(tmp_path / "renders") + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) + with patch(HANDLE_REQUEST_PATH, return_value=map_result): + directory = dest.create() + assert (tmp_path / "renders").is_dir() + assert directory.resolve() == tmp_path / "renders" + + def test_create_overwrite_existing_dir_succeeds(self, tmp_path: Path) -> None: + existing = tmp_path / "renders" + existing.mkdir() + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + dest = DirectoryDestination(str(existing), existing_dir_policy=ExistingFilePolicy.OVERWRITE) + with patch(HANDLE_REQUEST_PATH, return_value=map_result): + directory = dest.create() + assert existing.is_dir() + assert directory.resolve() == existing + + def test_create_fail_policy_on_existing_raises(self, tmp_path: Path) -> None: + existing = tmp_path / "renders" + existing.mkdir() + dest = DirectoryDestination(str(existing), existing_dir_policy=ExistingFilePolicy.FAIL) + with patch(HANDLE_REQUEST_PATH) as mock_handle, pytest.raises(DirectoryError): + dest.create() + mock_handle.assert_not_called() + + def test_create_returns_directory_with_absolute_location(self, tmp_path: Path) -> None: + dir_path = str(tmp_path / "output") + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) + with patch(HANDLE_REQUEST_PATH, return_value=map_result): + directory = dest.create() + assert Path(directory.location).is_absolute() + + def test_create_returns_directory_with_mapped_macro_when_inside_project(self, tmp_path: Path) -> None: + dir_path = str(tmp_path / "renders") + map_result = AttemptMapAbsolutePathToProjectResultSuccess( + result_details="OK", + mapped_path="{outputs}/renders", + ) + dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) + with patch(HANDLE_REQUEST_PATH, return_value=map_result): + directory = dest.create() + assert directory.location == "{outputs}/renders" + + +class TestDirectoryDestinationCreateVersioning: + """Tests for DirectoryDestination.create() in versioning (CREATE_NEW + MacroPath) mode.""" + + def test_versioning_first_available_used(self, tmp_path: Path) -> None: + missing_dir = tmp_path / "renders_v001" + + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v001"), + absolute_path=missing_dir, + ) + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, side_effect=[resolve_result, map_result]): + directory = dest.create() + + assert missing_dir.is_dir() + assert directory.location == "{outputs}/renders_v{_index:03}" + + def test_versioning_increments_index_when_first_taken(self, tmp_path: Path) -> None: + existing_dir = tmp_path / "renders_v001" + existing_dir.mkdir() + missing_dir = tmp_path / "renders_v002" + + resolve_result_1 = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v001"), + absolute_path=existing_dir, + ) + resolve_result_2 = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v002"), + absolute_path=missing_dir, + ) + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, side_effect=[resolve_result_1, resolve_result_2, map_result]): + directory = dest.create() + + assert missing_dir.is_dir() + assert directory.location == "{outputs}/renders_v{_index:03}" + + def test_versioning_handle_request_failure_raises_directory_error(self) -> None: + failure = GetPathForMacroResultFailure( + result_details="Macro resolution failed", + failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, + missing_variables={"outputs"}, + ) + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(DirectoryError): + dest.create() + + def test_versioning_all_indexes_exhausted_raises(self, tmp_path: Path) -> None: + # tmp_path always exists, so every probe returns an already-existing directory. + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v001"), + absolute_path=tmp_path, + ) + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=resolve_result), + patch("griptape_nodes.files.directory._MAX_VERSION_INDEX", 3), + pytest.raises(DirectoryError), + ): + dest.create() diff --git a/tests/unit/files/test_image_sequence.py b/tests/unit/files/test_image_sequence.py new file mode 100644 index 0000000000..ee6e52f768 --- /dev/null +++ b/tests/unit/files/test_image_sequence.py @@ -0,0 +1,356 @@ +"""Unit tests for ImageSequence and ImageSequenceDestination.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from griptape_nodes.common.macro_parser import ParsedMacro +from griptape_nodes.files.image_sequence import ( + ImageSequence, + ImageSequenceDestination, + ImageSequenceError, + build_versioned_sequence_destination, + frame_macro_to_hash_pattern, + hash_pattern_to_frame_macro, +) +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + GetPathForMacroResultFailure, + GetPathForMacroResultSuccess, + MacroPath, + PathResolutionFailureReason, +) + +HANDLE_REQUEST_PATH = "griptape_nodes.files.image_sequence.GriptapeNodes.handle_request" + + +class TestHashPatternConversion: + """Tests for hash_pattern_to_frame_macro and frame_macro_to_hash_pattern (pure functions).""" + + def test_hash_to_frame_macro_four_hashes(self) -> None: + assert hash_pattern_to_frame_macro("####") == "{frame:04}" + + def test_hash_to_frame_macro_two_hashes(self) -> None: + assert hash_pattern_to_frame_macro("##") == "{frame:02}" + + def test_hash_to_frame_macro_six_hashes(self) -> None: + assert hash_pattern_to_frame_macro("######") == "{frame:06}" + + def test_hash_to_frame_macro_with_prefix_and_suffix(self) -> None: + assert hash_pattern_to_frame_macro("render_####.exr") == "render_{frame:04}.exr" + + def test_hash_to_frame_macro_no_hashes_unchanged(self) -> None: + assert hash_pattern_to_frame_macro("frame.exr") == "frame.exr" + + def test_hash_to_frame_macro_single_hash(self) -> None: + assert hash_pattern_to_frame_macro("#") == "{frame:01}" + + def test_frame_macro_to_hash_with_explicit_width(self) -> None: + assert frame_macro_to_hash_pattern("{frame:06}") == "######" + + def test_frame_macro_to_hash_four_width(self) -> None: + assert frame_macro_to_hash_pattern("{frame:04}") == "####" + + def test_frame_macro_to_hash_no_width_defaults_to_4(self) -> None: + assert frame_macro_to_hash_pattern("{frame}") == "####" + + def test_frame_macro_to_hash_with_prefix_and_suffix(self) -> None: + assert frame_macro_to_hash_pattern("frame_{frame:04}.exr") == "frame_####.exr" + + def test_frame_macro_to_hash_no_frame_var_unchanged(self) -> None: + assert frame_macro_to_hash_pattern("frame.exr") == "frame.exr" + + def test_roundtrip_hash_to_macro_to_hash(self) -> None: + original = "render_####.exr" + assert frame_macro_to_hash_pattern(hash_pattern_to_frame_macro(original)) == original + + def test_roundtrip_macro_to_hash_to_macro(self) -> None: + original = "render_{frame:04}.exr" + assert hash_pattern_to_frame_macro(frame_macro_to_hash_pattern(original)) == original + + +class TestImageSequenceConstructor: + """Tests that ImageSequence constructor stores the frame macro without I/O.""" + + def test_stores_frame_macro(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + seq = ImageSequence(macro_path) + assert seq._frame_macro is macro_path + + def test_does_no_io(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + with patch(HANDLE_REQUEST_PATH) as mock_handle: + ImageSequence(macro_path) + mock_handle.assert_not_called() + + +class TestImageSequenceLocation: + """Tests for ImageSequence.location property.""" + + def test_location_returns_macro_template(self) -> None: + template = "{outputs}/frames/frame_{frame:04}.exr" + macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) + seq = ImageSequence(macro_path) + assert seq.location == template + + def test_location_no_io_performed(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) + with patch(HANDLE_REQUEST_PATH) as mock_handle: + seq = ImageSequence(macro_path) + _ = seq.location + mock_handle.assert_not_called() + + +class TestImageSequencePattern: + """Tests for ImageSequence.pattern property.""" + + def test_pattern_converts_frame_var_to_hashes(self) -> None: + template = "{outputs}/frames/frame_{frame:04}.exr" + macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) + seq = ImageSequence(macro_path) + assert seq.pattern == "{outputs}/frames/frame_####.exr" + + def test_pattern_with_six_digit_frame(self) -> None: + template = "renders/frame_{frame:06}.png" + macro_path = MacroPath(ParsedMacro(template), {}) + seq = ImageSequence(macro_path) + assert seq.pattern == "renders/frame_######.png" + + +class TestImageSequenceDirectory: + """Tests for ImageSequence.directory property.""" + + def test_directory_returns_parent_of_template(self) -> None: + template = "{outputs}/frames/frame_{frame:04}.exr" + macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) + seq = ImageSequence(macro_path) + directory = seq.directory + assert directory.location == "{outputs}/frames" + + def test_directory_no_io_performed(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) + with patch(HANDLE_REQUEST_PATH) as mock_handle: + seq = ImageSequence(macro_path) + _ = seq.directory + mock_handle.assert_not_called() + + +class TestImageSequenceFrame: + """Tests for ImageSequence.frame() method.""" + + def test_frame_returns_file_with_correct_frame_number(self) -> None: + frame_number = 5 + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + seq = ImageSequence(macro_path) + file = seq.frame(frame_number) + assert isinstance(file._file_path, MacroPath) + assert file._file_path.variables["frame"] == frame_number + + def test_frame_inherits_locked_index_variable(self) -> None: + locked_index = 7 + frame_number = 3 + macro_path = MacroPath( + ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {"_index": locked_index} + ) + seq = ImageSequence(macro_path) + file = seq.frame(frame_number) + assert isinstance(file._file_path, MacroPath) + assert file._file_path.variables["_index"] == locked_index + assert file._file_path.variables["frame"] == frame_number + + def test_frame_does_no_io(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + seq = ImageSequence(macro_path) + with patch(HANDLE_REQUEST_PATH) as mock_handle: + seq.frame(0) + mock_handle.assert_not_called() + + def test_frame_zero(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) + seq = ImageSequence(macro_path) + file = seq.frame(0) + assert isinstance(file._file_path, MacroPath) + assert file._file_path.variables["frame"] == 0 + + +class TestImageSequenceDestination: + """Tests for ImageSequenceDestination.""" + + def test_image_sequence_is_none_before_write(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + dest = ImageSequenceDestination(macro_path) + assert dest.image_sequence is None + + def test_frame_returns_file_destination(self) -> None: + from griptape_nodes.files.file import FileDestination + + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + dest = ImageSequenceDestination(macro_path) + frame_dest = dest.frame(1) + assert isinstance(frame_dest, FileDestination) + + def test_frame_destination_has_correct_frame_variable(self) -> None: + frame_number = 42 + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + dest = ImageSequenceDestination(macro_path) + frame_dest = dest.frame(frame_number) + assert isinstance(frame_dest._file._file_path, MacroPath) + assert frame_dest._file._file_path.variables["frame"] == frame_number + + def test_on_frame_written_sets_image_sequence(self) -> None: + from griptape_nodes.files.file import File + + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + dest = ImageSequenceDestination(macro_path) + assert dest.image_sequence is None + dest._on_frame_written(File("workspace/frame_0001.exr")) + assert dest.image_sequence is not None + assert isinstance(dest.image_sequence, ImageSequence) + + def test_image_sequence_not_reset_on_second_write(self) -> None: + from griptape_nodes.files.file import File + + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + dest = ImageSequenceDestination(macro_path) + dest._on_frame_written(File("workspace/frame_0001.exr")) + first_seq = dest.image_sequence + dest._on_frame_written(File("workspace/frame_0002.exr")) + assert dest.image_sequence is first_seq + + def test_image_sequence_uses_locked_macro(self) -> None: + from griptape_nodes.files.file import File + + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {"_index": 3}) + dest = ImageSequenceDestination(macro_path) + dest._on_frame_written(File("workspace/frame_0001.exr")) + assert dest.image_sequence is not None + assert dest.image_sequence._frame_macro is macro_path + + def test_defaults_overwrite_policy(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) + dest = ImageSequenceDestination(macro_path) + assert dest._existing_file_policy == ExistingFilePolicy.OVERWRITE + assert dest._create_parents is True + + def test_frame_write_destination_triggers_on_written_callback(self) -> None: + from griptape_nodes.files.file import File + from griptape_nodes.files.image_sequence import _FrameWriteDestination + + callback_calls: list[File] = [] + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + frame_path = MacroPath(macro_path.parsed_macro, {**macro_path.variables, "frame": 1}) + + frame_dest = _FrameWriteDestination( + frame_path, + existing_file_policy=ExistingFilePolicy.OVERWRITE, + create_parents=True, + on_written=callback_calls.append, + ) + + written_file = File("workspace/frame_0001.exr") + # Simulate the callback that fires after a successful write. + frame_dest._on_written(written_file) + + assert len(callback_calls) == 1 + assert callback_calls[0] is written_file + + +class TestBuildVersionedSequenceDestination: + """Tests for build_versioned_sequence_destination.""" + + def test_first_version_used_when_parent_missing(self, tmp_path: Path) -> None: + missing_parent = tmp_path / "renders_v001" + frame_path = missing_parent / "frame_0000.exr" + + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v001/frame_0000.exr"), + absolute_path=frame_path, + ) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + + with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + dest = build_versioned_sequence_destination(macro) + + assert dest._frame_macro.variables["_index"] == 1 + + def test_second_version_used_when_first_parent_exists(self, tmp_path: Path) -> None: + existing_parent = tmp_path / "renders_v001" + existing_parent.mkdir() + missing_parent = tmp_path / "renders_v002" + + resolve_result_1 = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v001/frame_0000.exr"), + absolute_path=existing_parent / "frame_0000.exr", + ) + resolve_result_2 = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v002/frame_0000.exr"), + absolute_path=missing_parent / "frame_0000.exr", + ) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + + expected_index = 2 + with patch(HANDLE_REQUEST_PATH, side_effect=[resolve_result_1, resolve_result_2]): + dest = build_versioned_sequence_destination(macro) + + assert dest._frame_macro.variables["_index"] == expected_index + + def test_raises_when_macro_resolution_fails(self) -> None: + failure = GetPathForMacroResultFailure( + result_details="Missing variables: outputs", + failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, + missing_variables={"outputs"}, + ) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + + with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(ImageSequenceError): + build_versioned_sequence_destination(macro) + + def test_raises_when_all_versions_exhausted(self, tmp_path: Path) -> None: + existing_parent = tmp_path + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("frame_0000.exr"), + absolute_path=existing_parent / "frame_0000.exr", + ) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=resolve_result), + patch("griptape_nodes.files.image_sequence._MAX_VERSION_INDEX", 3), + pytest.raises(ImageSequenceError), + ): + build_versioned_sequence_destination(macro) + + def test_locks_index_into_returned_destination_variables(self, tmp_path: Path) -> None: + missing_parent = tmp_path / "seq_v001" + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("seq_v001/frame_0000.exr"), + absolute_path=missing_parent / "frame_0000.exr", + ) + macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{frame:04}.exr"), {"extra": "value"}) + + with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + dest = build_versioned_sequence_destination(macro) + + assert "_index" in dest._frame_macro.variables + assert "extra" in dest._frame_macro.variables + assert "frame" not in dest._frame_macro.variables + + def test_existing_file_policy_forwarded(self, tmp_path: Path) -> None: + missing_parent = tmp_path / "seq_v001" + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("seq_v001/frame_0000.exr"), + absolute_path=missing_parent / "frame_0000.exr", + ) + macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{frame:04}.exr"), {}) + + with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + dest = build_versioned_sequence_destination(macro, existing_file_policy=ExistingFilePolicy.FAIL) + + assert dest._existing_file_policy == ExistingFilePolicy.FAIL From 451ddbeb07c9b9abbe82ed08a3a184f21ffb5c3b Mon Sep 17 00:00:00 2001 From: Collin Dutter Date: Tue, 19 May 2026 09:36:33 -0700 Subject: [PATCH 03/22] test: add NodeExecutor contract tests (#4631) --- .../unit/common/test_node_executor_execute.py | 272 ++++++++++++++++ .../unit/common/test_node_executor_helpers.py | 297 ++++++++++++++++++ .../common/test_node_executor_loop_helpers.py | 255 +++++++++++++++ 3 files changed, 824 insertions(+) create mode 100644 tests/unit/common/test_node_executor_execute.py create mode 100644 tests/unit/common/test_node_executor_helpers.py create mode 100644 tests/unit/common/test_node_executor_loop_helpers.py diff --git a/tests/unit/common/test_node_executor_execute.py b/tests/unit/common/test_node_executor_execute.py new file mode 100644 index 0000000000..23a6fad6b1 --- /dev/null +++ b/tests/unit/common/test_node_executor_execute.py @@ -0,0 +1,272 @@ +"""Contract tests for NodeExecutor.execute(). + +These tests describe the observable contract of `NodeExecutor.execute(node)` +without relying on internals. They cover three behavioral clusters: + +1. Dispatch contract: a plain BaseNode results in an ExecuteNodeRequest whose + payload reflects the node's name, parameter values, and metadata at the + moment of dispatch (defensive copies, not live references). +2. Failure contract: when the dispatched request returns a failure result, + execute() raises RuntimeError that carries the node name and the failure + details, and outputs are not copied back onto the node. +3. Special-node routing: BaseWhileNodeGroup, BaseIterativeNodeGroup, + BaseIterativeEndNode, and SubflowNodeGroup (with LOCAL_EXECUTION) take + their dedicated paths and do NOT dispatch an ExecuteNodeRequest. +""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from griptape_nodes.common.node_executor import NodeExecutor +from griptape_nodes.exe_types.base_iterative_nodes import BaseIterativeEndNode +from griptape_nodes.exe_types.node_groups import ( + BaseIterativeNodeGroup, + BaseWhileNodeGroup, + SubflowNodeGroup, +) +from griptape_nodes.exe_types.node_types import LOCAL_EXECUTION +from griptape_nodes.retained_mode.events.execution_events import ( + ExecuteNodeRequest, + ExecuteNodeResultFailure, + ExecuteNodeResultSuccess, +) + +_GRIPTAPE_NODES_PATH = "griptape_nodes.common.node_executor.GriptapeNodes" + + +def _make_executor() -> NodeExecutor: + return NodeExecutor.__new__(NodeExecutor) + + +def _make_node( + name: str = "TestNode", + parameter_values: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, +) -> MagicMock: + node = MagicMock() + node.name = name + node.parameter_values = parameter_values if parameter_values is not None else {} + node.parameter_output_values = {} + node.metadata = metadata if metadata is not None else {} + return node + + +def _success_result(outputs: dict[str, Any] | None = None) -> ExecuteNodeResultSuccess: + return ExecuteNodeResultSuccess( + result_details="ok", + parameter_output_values=outputs or {}, + ) + + +class TestExecuteDispatchContract: + """A plain BaseNode dispatches a single ExecuteNodeRequest carrying its state.""" + + @pytest.mark.asyncio + async def test_dispatches_execute_node_request_for_plain_node(self) -> None: + node = _make_node(name="Greeter") + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=_success_result()) + await _make_executor().execute(node) + + mock_gn.ahandle_request.assert_awaited_once() + request = mock_gn.ahandle_request.call_args.args[0] + assert isinstance(request, ExecuteNodeRequest) + assert request.node_name == "Greeter" + + @pytest.mark.asyncio + async def test_dispatches_current_parameter_values(self) -> None: + node = _make_node(parameter_values={"in": "hi", "n": 3}) + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=_success_result()) + await _make_executor().execute(node) + + request = mock_gn.ahandle_request.call_args.args[0] + assert request.parameter_values == {"in": "hi", "n": 3} + + @pytest.mark.asyncio + async def test_dispatched_parameter_values_are_independent_copy(self) -> None: + """Mutating node.parameter_values after dispatch must not leak into the request.""" + node = _make_node(parameter_values={"in": "hi"}) + captured: dict[str, Any] = {} + + async def capture(req: Any) -> ExecuteNodeResultSuccess: + # Mutate the node's live dict; the request's snapshot must not change. + node.parameter_values["in"] = "MUTATED" + captured["params"] = req.parameter_values + return _success_result() + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(side_effect=capture) + await _make_executor().execute(node) + + assert captured["params"] == {"in": "hi"} + + @pytest.mark.asyncio + async def test_dispatches_node_metadata(self) -> None: + node = _make_node(metadata={"node_type": "Foo", "library": "Bar"}) + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=_success_result()) + await _make_executor().execute(node) + + request = mock_gn.ahandle_request.call_args.args[0] + assert request.node_metadata == {"node_type": "Foo", "library": "Bar"} + + @pytest.mark.asyncio + async def test_dispatched_metadata_is_independent_copy(self) -> None: + node = _make_node(metadata={"node_type": "Foo", "library": "Bar"}) + captured: dict[str, Any] = {} + + async def capture(req: Any) -> ExecuteNodeResultSuccess: + node.metadata["library"] = "MUTATED" + captured["meta"] = req.node_metadata + return _success_result() + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(side_effect=capture) + await _make_executor().execute(node) + + assert captured["meta"] == {"node_type": "Foo", "library": "Bar"} + + @pytest.mark.asyncio + async def test_outputs_from_result_are_copied_back_to_node(self) -> None: + node = _make_node() + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock( + return_value=_success_result({"a": 1, "b": "two"}), + ) + await _make_executor().execute(node) + + assert node.parameter_output_values == {"a": 1, "b": "two"} + + +class TestExecuteFailureContract: + """A non-success result is converted into a RuntimeError that explains the failure.""" + + @pytest.mark.asyncio + async def test_raises_runtime_error_when_result_is_failure(self) -> None: + node = _make_node(name="Broken") + failure = ExecuteNodeResultFailure(result_details="boom") + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=failure) + + with pytest.raises(RuntimeError): + await _make_executor().execute(node) + + @pytest.mark.asyncio + async def test_runtime_error_mentions_node_name(self) -> None: + node = _make_node(name="Broken") + failure = ExecuteNodeResultFailure(result_details="boom") + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=failure) + + with pytest.raises(RuntimeError, match="Broken"): + await _make_executor().execute(node) + + @pytest.mark.asyncio + async def test_runtime_error_mentions_failure_details(self) -> None: + node = _make_node(name="Broken") + failure = ExecuteNodeResultFailure(result_details="kaboom-detail") + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=failure) + + with pytest.raises(RuntimeError, match="kaboom-detail"): + await _make_executor().execute(node) + + @pytest.mark.asyncio + async def test_outputs_are_not_copied_back_on_failure(self) -> None: + node = _make_node() + failure = ExecuteNodeResultFailure(result_details="boom") + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=failure) + + with pytest.raises(RuntimeError): + await _make_executor().execute(node) + + assert node.parameter_output_values == {} + + +class TestExecuteSpecialNodeRouting: + """Special node types take dedicated paths and never dispatch ExecuteNodeRequest.""" + + @pytest.mark.asyncio + async def test_basewhilenodegroup_routes_to_handle_while_group_execution(self) -> None: + node = MagicMock(spec=BaseWhileNodeGroup) + node.name = "WhileGroup" + + executor = _make_executor() + with ( + patch(_GRIPTAPE_NODES_PATH) as mock_gn, + patch.object(NodeExecutor, "handle_while_group_execution", new_callable=AsyncMock) as mock_while, + patch.object(NodeExecutor, "handle_iterative_group_execution", new_callable=AsyncMock) as mock_iter, + patch.object(NodeExecutor, "handle_loop_execution", new_callable=AsyncMock) as mock_loop, + ): + mock_gn.ahandle_request = AsyncMock() + await executor.execute(node) + + mock_while.assert_awaited_once_with(node) + mock_iter.assert_not_awaited() + mock_loop.assert_not_awaited() + mock_gn.ahandle_request.assert_not_awaited() + + @pytest.mark.asyncio + async def test_baseiterativenodegroup_routes_to_handle_iterative_group_execution(self) -> None: + node = MagicMock(spec=BaseIterativeNodeGroup) + node.name = "IterGroup" + + executor = _make_executor() + with ( + patch(_GRIPTAPE_NODES_PATH) as mock_gn, + patch.object(NodeExecutor, "handle_while_group_execution", new_callable=AsyncMock) as mock_while, + patch.object(NodeExecutor, "handle_iterative_group_execution", new_callable=AsyncMock) as mock_iter, + patch.object(NodeExecutor, "handle_loop_execution", new_callable=AsyncMock) as mock_loop, + ): + mock_gn.ahandle_request = AsyncMock() + await executor.execute(node) + + mock_iter.assert_awaited_once_with(node) + mock_while.assert_not_awaited() + mock_loop.assert_not_awaited() + mock_gn.ahandle_request.assert_not_awaited() + + @pytest.mark.asyncio + async def test_baseiterativeendnode_routes_to_handle_loop_execution(self) -> None: + node = MagicMock(spec=BaseIterativeEndNode) + node.name = "EndLoop" + + executor = _make_executor() + with ( + patch(_GRIPTAPE_NODES_PATH) as mock_gn, + patch.object(NodeExecutor, "handle_loop_execution", new_callable=AsyncMock) as mock_loop, + ): + mock_gn.ahandle_request = AsyncMock() + await executor.execute(node) + + mock_loop.assert_awaited_once_with(node) + mock_gn.ahandle_request.assert_not_awaited() + + @pytest.mark.asyncio + async def test_subflownodegroup_local_execution_calls_aprocess(self) -> None: + """When execution_environment is LOCAL_EXECUTION, run aprocess in-process and skip dispatch.""" + node = MagicMock(spec=SubflowNodeGroup) + node.name = "Subflow" + node.execution_environment = MagicMock() + node.execution_environment.name = "execution_environment" + node.get_parameter_value = MagicMock(return_value=LOCAL_EXECUTION) + node.aprocess = AsyncMock() + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock() + await _make_executor().execute(node) + + node.aprocess.assert_awaited_once() + mock_gn.ahandle_request.assert_not_awaited() diff --git a/tests/unit/common/test_node_executor_helpers.py b/tests/unit/common/test_node_executor_helpers.py new file mode 100644 index 0000000000..8689650af1 --- /dev/null +++ b/tests/unit/common/test_node_executor_helpers.py @@ -0,0 +1,297 @@ +"""Contract tests for NodeExecutor helpers and SubflowNodeGroup execute() branches. + +These tests describe the observable contract of: + +* ``NodeExecutor.get_workflow_handler`` - returns the registered handler for a + library, or raises ``ValueError`` with the library name in the message. +* ``NodeExecutor._deserialize_parameter_value`` - resolves UUID-referenced + pickled bytes back into Python objects, with explicit fallbacks when the + stored value is not a UUID reference, not a string, or not unpicklable. +* ``NodeExecutor._extract_parameter_output_values`` - merges per-node output + dicts from a subprocess result, using the deserializer for UUID references + and falling back to a pre-mapping flat shape for backward compatibility. +* ``NodeExecutor.execute`` - the remaining ``SubflowNodeGroup`` branches + (private execution, library-name execution) and the unexpected-result-type + edge case. +""" + +import pickle +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from griptape_nodes.common.node_executor import NodeExecutor +from griptape_nodes.exe_types.node_groups import SubflowNodeGroup +from griptape_nodes.exe_types.node_types import LOCAL_EXECUTION, PRIVATE_EXECUTION +from griptape_nodes.retained_mode.events.execution_events import ExecuteNodeResultSuccess +from griptape_nodes.retained_mode.events.workflow_events import PublishWorkflowRequest + +_GRIPTAPE_NODES_PATH = "griptape_nodes.common.node_executor.GriptapeNodes" + + +def _make_executor() -> NodeExecutor: + return NodeExecutor.__new__(NodeExecutor) + + +def _make_subflow_node(execution_type: str) -> MagicMock: + node = MagicMock(spec=SubflowNodeGroup) + node.name = "Subflow" + node.execution_environment = MagicMock() + node.execution_environment.name = "execution_environment" + node.get_parameter_value = MagicMock(return_value=execution_type) + node.aprocess = AsyncMock() + node.subflow_execution_component = MagicMock() + node.subflow_execution_component.clear_execution_state = MagicMock() + return node + + +class TestGetWorkflowHandler: + """get_workflow_handler returns the PublishWorkflowRequest handler for a library.""" + + def test_returns_registered_handler_for_known_library(self) -> None: + sentinel_handler = object() + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_lm = MagicMock() + mock_lm.get_registered_event_handlers.return_value = {"my_lib": sentinel_handler} + mock_gn.LibraryManager.return_value = mock_lm + + handler = _make_executor().get_workflow_handler("my_lib") + + assert handler is sentinel_handler + mock_lm.get_registered_event_handlers.assert_called_once_with(PublishWorkflowRequest) + + def test_raises_value_error_when_library_unregistered(self) -> None: + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_lm = MagicMock() + mock_lm.get_registered_event_handlers.return_value = {} + mock_gn.LibraryManager.return_value = mock_lm + + with pytest.raises(ValueError, match="missing_lib"): + _make_executor().get_workflow_handler("missing_lib") + + +class TestDeserializeParameterValue: + """_deserialize_parameter_value resolves UUID-referenced pickled values.""" + + def test_returns_param_value_unchanged_when_not_a_uuid_reference(self) -> None: + executor = _make_executor() + sentinel = object() + + result = executor._deserialize_parameter_value( + param_name="x", + param_value=sentinel, + unique_uuid_to_values={"some-uuid": "irrelevant"}, + ) + + assert result is sentinel + + def test_returns_stored_value_directly_when_not_a_string(self) -> None: + executor = _make_executor() + stored = {"already": "deserialized"} + + result = executor._deserialize_parameter_value( + param_name="x", + param_value="uuid-1", + unique_uuid_to_values={"uuid-1": stored}, + ) + + assert result is stored + + def test_unpickles_string_representation_of_bytes(self) -> None: + executor = _make_executor() + original = {"answer": 42, "items": [1, 2, 3]} + pickled_repr = repr(pickle.dumps(original)) # e.g. "b'\\x80\\x04...'" + + result = executor._deserialize_parameter_value( + param_name="payload", + param_value="uuid-1", + unique_uuid_to_values={"uuid-1": pickled_repr}, + ) + + assert result == original + + def test_returns_original_string_when_eval_fails(self) -> None: + executor = _make_executor() + + result = executor._deserialize_parameter_value( + param_name="payload", + param_value="uuid-1", + unique_uuid_to_values={"uuid-1": "not a bytes literal"}, + ) + + assert result == "not a bytes literal" + + def test_returns_original_string_when_eval_yields_non_bytes(self) -> None: + """If literal_eval returns something that isn't bytes, fall through to the string.""" + executor = _make_executor() + + result = executor._deserialize_parameter_value( + param_name="payload", + param_value="uuid-1", + # "[1, 2, 3]" evals to a list, not bytes + unique_uuid_to_values={"uuid-1": "[1, 2, 3]"}, + ) + + assert result == "[1, 2, 3]" + + def test_returns_original_string_when_unpickle_fails(self) -> None: + executor = _make_executor() + # Valid bytes literal but not a valid pickle stream. + bogus = repr(b"\x99garbage\x00") + + result = executor._deserialize_parameter_value( + param_name="payload", + param_value="uuid-1", + unique_uuid_to_values={"uuid-1": bogus}, + ) + + assert result == bogus + + +class TestExtractParameterOutputValues: + """_extract_parameter_output_values merges per-node outputs from a subprocess result.""" + + def test_returns_empty_dict_for_empty_input(self) -> None: + assert _make_executor()._extract_parameter_output_values({}) == {} + + def test_passes_through_values_when_no_uuid_mapping(self) -> None: + executor = _make_executor() + subprocess_result: dict[str, Any] = { + "node_a": {"parameter_output_values": {"out1": 1, "out2": "two"}}, + } + + assert executor._extract_parameter_output_values(subprocess_result) == {"out1": 1, "out2": "two"} + + def test_deserializes_uuid_referenced_values(self) -> None: + executor = _make_executor() + original = [10, 20, 30] + pickled_repr = repr(pickle.dumps(original)) + subprocess_result: dict[str, Any] = { + "node_a": { + "parameter_output_values": {"items": "uuid-1"}, + "unique_parameter_uuid_to_values": {"uuid-1": pickled_repr}, + }, + } + + assert executor._extract_parameter_output_values(subprocess_result) == {"items": original} + + def test_merges_outputs_from_multiple_result_dicts(self) -> None: + executor = _make_executor() + subprocess_result: dict[str, Any] = { + "node_a": {"parameter_output_values": {"a": 1}}, + "node_b": {"parameter_output_values": {"b": 2}}, + } + + assert executor._extract_parameter_output_values(subprocess_result) == {"a": 1, "b": 2} + + def test_backward_compatible_with_flat_result_shape(self) -> None: + """Old flat structure: result dict directly contains output keys.""" + executor = _make_executor() + subprocess_result: dict[str, Any] = { + "node_a": {"out1": "hello", "out2": "world"}, + } + + assert executor._extract_parameter_output_values(subprocess_result) == {"out1": "hello", "out2": "world"} + + +class TestExecuteSubflowNodeGroupBranches: + """SubflowNodeGroup non-local branches delegate to dedicated workflow paths.""" + + @pytest.mark.asyncio + async def test_private_execution_calls_private_workflow_path(self) -> None: + node = _make_subflow_node(PRIVATE_EXECUTION) + + with ( + patch(_GRIPTAPE_NODES_PATH) as mock_gn, + patch.object(NodeExecutor, "_execute_private_workflow", new_callable=AsyncMock) as mock_private, + patch.object(NodeExecutor, "_execute_library_workflow", new_callable=AsyncMock) as mock_library, + ): + mock_gn.ahandle_request = AsyncMock() + await _make_executor().execute(node) + + mock_private.assert_awaited_once_with(node) + mock_library.assert_not_awaited() + node.aprocess.assert_not_awaited() + mock_gn.ahandle_request.assert_not_awaited() + + @pytest.mark.asyncio + async def test_library_execution_routes_through_library_workflow(self) -> None: + """A non-Local, non-Private execution_environment is treated as a library name.""" + node = _make_subflow_node("some_library_name") + + with ( + patch(_GRIPTAPE_NODES_PATH) as mock_gn, + patch.object(NodeExecutor, "_execute_private_workflow", new_callable=AsyncMock) as mock_private, + patch.object(NodeExecutor, "_execute_library_workflow", new_callable=AsyncMock) as mock_library, + ): + mock_gn.ahandle_request = AsyncMock() + await _make_executor().execute(node) + + mock_library.assert_awaited_once_with(node, "some_library_name") + mock_private.assert_not_awaited() + node.aprocess.assert_not_awaited() + mock_gn.ahandle_request.assert_not_awaited() + + @pytest.mark.asyncio + async def test_subprocess_paths_clear_execution_state_first(self) -> None: + """Both PRIVATE and library paths must clear execution state before running.""" + node = _make_subflow_node(PRIVATE_EXECUTION) + + with ( + patch(_GRIPTAPE_NODES_PATH), + patch.object(NodeExecutor, "_execute_private_workflow", new_callable=AsyncMock), + ): + await _make_executor().execute(node) + + node.subflow_execution_component.clear_execution_state.assert_called_once() + + @pytest.mark.asyncio + async def test_local_execution_does_not_clear_execution_state(self) -> None: + """LOCAL_EXECUTION runs aprocess directly; clearing subprocess state is unnecessary.""" + node = _make_subflow_node(LOCAL_EXECUTION) + + with patch(_GRIPTAPE_NODES_PATH): + await _make_executor().execute(node) + + node.subflow_execution_component.clear_execution_state.assert_not_called() + + +class TestExecuteUnexpectedResultType: + """Anything that isn't an ExecuteNodeResultSuccess is surfaced as a RuntimeError.""" + + @pytest.mark.asyncio + async def test_raises_when_result_is_not_a_success_payload(self) -> None: + node = MagicMock() + node.name = "Weird" + node.parameter_values = {} + node.parameter_output_values = {} + node.metadata = {} + + not_a_payload: Any = "not a payload at all" + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock(return_value=not_a_payload) + + with pytest.raises(RuntimeError, match="Weird"): + await _make_executor().execute(node) + + +class TestExecuteSuccessReturnsNone: + """The contract of execute() is to return None; all output flows via parameter_output_values.""" + + @pytest.mark.asyncio + async def test_returns_none_on_success(self) -> None: + node = MagicMock() + node.name = "Plain" + node.parameter_values = {} + node.parameter_output_values = {} + node.metadata = {} + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.ahandle_request = AsyncMock( + return_value=ExecuteNodeResultSuccess(result_details="ok", parameter_output_values={"x": 1}), + ) + result = await _make_executor().execute(node) + + assert result is None diff --git a/tests/unit/common/test_node_executor_loop_helpers.py b/tests/unit/common/test_node_executor_loop_helpers.py new file mode 100644 index 0000000000..aaeeed81c9 --- /dev/null +++ b/tests/unit/common/test_node_executor_loop_helpers.py @@ -0,0 +1,255 @@ +"""Contract tests for NodeExecutor loop-control helpers. + +These tests cover small, near-pure helpers that decide loop control flow: + +* ``get_node_parameter_mappings`` - select the start or end mapping out of a + PackageNodesAsSerializedFlowResultSuccess. +* ``_should_break_loop`` - decide whether a packaged loop body's End node is + signaling a break. +* ``_check_control_source_fired`` - decide whether a (source_node, source_param) + pair has fired its control output. +* ``_find_source_for_control_param`` - return the first source for a given + control parameter name, or None. +""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from griptape_nodes.common.node_executor import NodeExecutor +from griptape_nodes.exe_types.base_iterative_nodes import BaseIterativeEndNode + +_GRIPTAPE_NODES_PATH = "griptape_nodes.common.node_executor.GriptapeNodes" + + +def _make_executor() -> NodeExecutor: + return NodeExecutor.__new__(NodeExecutor) + + +def _make_package_result( + *, + start_node_name: str = "StartPkg", + end_node_name: str = "EndPkg", + start_param_mappings: dict[str, Any] | None = None, + end_param_mappings: dict[str, Any] | None = None, +) -> MagicMock: + """Mock PackageNodesAsSerializedFlowResultSuccess with start/end mappings at indices 0/1.""" + package_result = MagicMock() + start_mapping = MagicMock() + start_mapping.node_name = start_node_name + start_mapping.parameter_mappings = start_param_mappings or {} + end_mapping = MagicMock() + end_mapping.node_name = end_node_name + end_mapping.parameter_mappings = end_param_mappings or {} + package_result.parameter_name_mappings = [start_mapping, end_mapping] + return package_result + + +class TestGetNodeParameterMappings: + """Returns index 0 for 'start', index 1 for 'end'; raises for anything else.""" + + def test_returns_start_mapping_for_start(self) -> None: + package = _make_package_result(start_node_name="MyStart") + mapping = _make_executor().get_node_parameter_mappings(package, "start") + assert mapping.node_name == "MyStart" + + def test_returns_end_mapping_for_end(self) -> None: + package = _make_package_result(end_node_name="MyEnd") + mapping = _make_executor().get_node_parameter_mappings(package, "end") + assert mapping.node_name == "MyEnd" + + def test_is_case_insensitive(self) -> None: + package = _make_package_result(start_node_name="MyStart", end_node_name="MyEnd") + executor = _make_executor() + assert executor.get_node_parameter_mappings(package, "START").node_name == "MyStart" + assert executor.get_node_parameter_mappings(package, "End").node_name == "MyEnd" + + def test_raises_value_error_for_other_strings(self) -> None: + package = _make_package_result() + with pytest.raises(ValueError, match="middle"): + _make_executor().get_node_parameter_mappings(package, "middle") + + +class TestShouldBreakLoop: + """_should_break_loop returns True iff the deserialized End node signals a break.""" + + @staticmethod + def _make_end_node( + *, + is_iterative_end: bool = True, + next_control_output: Any = None, + break_signal: Any = None, + ) -> Any: + if not is_iterative_end: + node = MagicMock() + return node + node = MagicMock(spec=BaseIterativeEndNode) + node.get_next_control_output.return_value = next_control_output + node.break_loop_signal_output = break_signal + return node + + def test_returns_false_when_end_name_missing_from_mappings(self) -> None: + package = _make_package_result(end_node_name="EndPkg") + with patch(_GRIPTAPE_NODES_PATH): + result = _make_executor()._should_break_loop({}, package) + assert result is False + + def test_returns_false_when_deserialized_end_node_not_found(self) -> None: + package = _make_package_result(end_node_name="EndPkg") + mappings = {"EndPkg": "EndPkg_inst1"} + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = None + result = _make_executor()._should_break_loop(mappings, package) + + assert result is False + + def test_returns_false_when_deserialized_node_is_not_iterative_end(self) -> None: + package = _make_package_result(end_node_name="EndPkg") + mappings = {"EndPkg": "EndPkg_inst1"} + non_iterative_node = MagicMock() # Not a BaseIterativeEndNode + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = non_iterative_node + result = _make_executor()._should_break_loop(mappings, package) + + assert result is False + + def test_returns_false_when_no_next_control_output(self) -> None: + package = _make_package_result(end_node_name="EndPkg") + mappings = {"EndPkg": "EndPkg_inst1"} + end_node = self._make_end_node(next_control_output=None) + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = end_node + result = _make_executor()._should_break_loop(mappings, package) + + assert result is False + + def test_returns_false_when_next_control_output_is_not_break_signal(self) -> None: + package = _make_package_result(end_node_name="EndPkg") + mappings = {"EndPkg": "EndPkg_inst1"} + not_break = object() + break_signal = object() + end_node = self._make_end_node(next_control_output=not_break, break_signal=break_signal) + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = end_node + result = _make_executor()._should_break_loop(mappings, package) + + assert result is False + + def test_returns_true_when_next_control_output_matches_break_signal(self) -> None: + package = _make_package_result(end_node_name="EndPkg") + mappings = {"EndPkg": "EndPkg_inst1"} + break_signal = object() + end_node = self._make_end_node(next_control_output=break_signal, break_signal=break_signal) + + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = end_node + result = _make_executor()._should_break_loop(mappings, package) + + assert result is True + + +class TestCheckControlSourceFired: + """_check_control_source_fired matches a node's next control output to a parameter.""" + + @staticmethod + def _make_source_node(*, next_control_output: Any, params: dict[str, Any] | None = None) -> Any: + node = MagicMock() + node.get_next_control_output.return_value = next_control_output + params = params or {} + node.get_parameter_by_name.side_effect = params.get + return node + + def test_returns_false_when_source_is_none(self) -> None: + with patch(_GRIPTAPE_NODES_PATH): + assert _make_executor()._check_control_source_fired(None, {}) is False + + def test_returns_false_when_source_node_not_in_mappings(self) -> None: + with patch(_GRIPTAPE_NODES_PATH): + result = _make_executor()._check_control_source_fired(("SrcOrig", "out"), {}) + assert result is False + + def test_returns_false_when_node_manager_raises_value_error(self) -> None: + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.side_effect = ValueError("not found") + result = _make_executor()._check_control_source_fired( + ("SrcOrig", "out"), + {"SrcOrig": "Src_inst1"}, + ) + assert result is False + + def test_returns_false_when_node_manager_returns_none(self) -> None: + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = None + result = _make_executor()._check_control_source_fired( + ("SrcOrig", "out"), + {"SrcOrig": "Src_inst1"}, + ) + assert result is False + + def test_returns_false_when_no_next_control_output(self) -> None: + node = self._make_source_node(next_control_output=None) + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = node + result = _make_executor()._check_control_source_fired( + ("SrcOrig", "out"), + {"SrcOrig": "Src_inst1"}, + ) + assert result is False + + def test_returns_true_when_next_control_output_matches_parameter(self) -> None: + target_param = MagicMock() + target_param.name = "out" + node = self._make_source_node( + next_control_output=target_param, + params={"out": target_param}, + ) + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = node + result = _make_executor()._check_control_source_fired( + ("SrcOrig", "out"), + {"SrcOrig": "Src_inst1"}, + ) + assert result is True + + def test_returns_false_when_next_control_output_is_a_different_parameter(self) -> None: + wrong_param = MagicMock(name="wrong") + target_param = MagicMock(name="target") + node = self._make_source_node( + next_control_output=wrong_param, + params={"out": target_param}, + ) + with patch(_GRIPTAPE_NODES_PATH) as mock_gn: + mock_gn.NodeManager.return_value.get_node_by_name.return_value = node + result = _make_executor()._check_control_source_fired( + ("SrcOrig", "out"), + {"SrcOrig": "Src_inst1"}, + ) + assert result is False + + +class TestFindSourceForControlParam: + """_find_source_for_control_param returns the first source from the multi-source helper.""" + + def test_returns_first_source_when_multiple_present(self) -> None: + executor = _make_executor() + with patch.object( + NodeExecutor, + "_find_sources_for_control_param", + return_value=[("A", "out"), ("B", "out")], + ) as mock_multi: + result = executor._find_source_for_control_param([], "break_loop") + + assert result == ("A", "out") + mock_multi.assert_called_once_with([], "break_loop") + + def test_returns_none_when_no_sources(self) -> None: + executor = _make_executor() + with patch.object(NodeExecutor, "_find_sources_for_control_param", return_value=[]): + result = executor._find_source_for_control_param([], "break_loop") + + assert result is None From 7eef4be7fbb18a77bb0d13b3c0faed08f3f2c339 Mon Sep 17 00:00:00 2001 From: Collin Dutter Date: Tue, 19 May 2026 10:41:31 -0700 Subject: [PATCH 04/22] fix: register libraries inside generated build_workflow() (#4602) * fix: register libraries inside generated build_workflow() Closes #4584 * test: add cold-runtime and e2e coverage for #4584 - Unit test exec()s a generated workflow under a recording stub for ahandle_request and asserts every RegisterLibraryFromFileRequest precedes every CreateNodeRequest in build_workflow(). - New tests/e2e tier spawns a fresh python subprocess against a generator- produced workflow that uses an Echo Library fixture, verifying the standalone path produces real nodes (not ErrorProxyNode) end-to-end. - Makefile gains `make test/e2e`; `make test` now runs unit + integration + e2e. * ci: add e2e test job and rename unit-tests workflow to tests Pulls e2e into the same triggers (push to main, PR to main, merge_group) on ubuntu-latest as a separate job. Workflow file renamed from unit-tests.yml to tests.yml and the displayed name changed to 'Tests' since it now covers more than just unit. * ci: also run e2e tests on windows --- .../workflows/{unit-tests.yml => tests.yml} | 33 ++- Makefile | 6 +- .../managers/workflow_manager.py | 57 +++- tests/e2e/__init__.py | 0 tests/e2e/fixtures/__init__.py | 0 tests/e2e/fixtures/echo_library/__init__.py | 0 tests/e2e/fixtures/echo_library/echo_node.py | 38 +++ .../echo_library/griptape_nodes_library.json | 35 +++ .../e2e/test_standalone_workflow_execution.py | 257 ++++++++++++++++++ .../managers/test_context_manager.py | 1 + .../managers/test_workflow_manager.py | 147 ++++++++++ 11 files changed, 564 insertions(+), 10 deletions(-) rename .github/workflows/{unit-tests.yml => tests.yml} (63%) create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/fixtures/__init__.py create mode 100644 tests/e2e/fixtures/echo_library/__init__.py create mode 100644 tests/e2e/fixtures/echo_library/echo_node.py create mode 100644 tests/e2e/fixtures/echo_library/griptape_nodes_library.json create mode 100644 tests/e2e/test_standalone_workflow_execution.py diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/tests.yml similarity index 63% rename from .github/workflows/unit-tests.yml rename to .github/workflows/tests.yml index 7e2a94b3c6..324016570e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -name: Unit Tests +name: Tests on: push: @@ -56,3 +56,34 @@ jobs: # make (e=2): The system cannot find the file specified. # make: *** [Makefile:24: test/unit] Error 2 run: uv run pytest -n auto tests/unit + e2e-test-ubuntu: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12"] + steps: + - name: Checkout actions + uses: actions/checkout@v6 + - name: Init environment + uses: ./.github/actions/init-environment + - name: Run e2e tests + run: make test/e2e + e2e-test-windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12"] + defaults: + run: + shell: bash + steps: + - name: Checkout actions + uses: actions/checkout@v6 + - name: Init environment + uses: ./.github/actions/init-environment + - name: Run e2e tests + # Mirrors unit-test-windows: invoke pytest directly because make on Windows + # cannot resolve the uv command line through CreateProcess. + run: uv run pytest tests/e2e diff --git a/Makefile b/Makefile index d36dcbf727..7321019241 100644 --- a/Makefile +++ b/Makefile @@ -99,7 +99,7 @@ check/spell: @uv run typos .PHONY: test ## Run all tests. -test: test/unit test/integration +test: test/unit test/integration test/e2e .PHONY: test/unit test/unit: ## Run unit tests. @@ -117,6 +117,10 @@ test/coverage: ## Run all tests with coverage. test/integration: ## Run integration tests. @uv run pytest -n auto tests/integration +.PHONY: test/e2e +test/e2e: ## Run end-to-end tests (spawns subprocesses; slower than unit/integration). + @uv run pytest tests/e2e + .PHONY: docs docs: ## Build documentation. @uv run python -m mkdocs build --clean --strict diff --git a/src/griptape_nodes/retained_mode/managers/workflow_manager.py b/src/griptape_nodes/retained_mode/managers/workflow_manager.py index 49291d1b2d..6a296d2604 100644 --- a/src/griptape_nodes/retained_mode/managers/workflow_manager.py +++ b/src/griptape_nodes/retained_mode/managers/workflow_manager.py @@ -2507,15 +2507,18 @@ def _generate_workflow_file_content( # noqa: PLR0912, PLR0915, C901 # Graph-building statements accumulate into the body of `async def build_workflow()`, # so the emitted workflow file only mutates engine state when build_workflow() is awaited. - # Library resolution is handled declaratively by WorkflowManager.run_workflow at load time - # via workflow_metadata.node_libraries_referenced; generated files no longer embed - # RegisterLibraryFromFileRequest calls. Worker-backed libraries spawn a subprocess at - # registration, so registering from inside exec() would recursively start a worker from - # code already running on one. Pre-registering via _ensure_libraries_for_workflow breaks - # that cycle. + # build_workflow() also registers every library named in the workflow metadata header so + # the file is self-sufficient: it works whether it is loaded by WorkflowManager.run_workflow + # (which also calls _ensure_libraries_for_workflow before exec) or executed directly as a + # standalone script via LocalWorkflowExecutor (which has no equivalent pre-exec hook). + # RegisterLibraryFromFileRequest is idempotent, so the redundant engine-side call is safe. + library_names = [lib.library_name for lib in workflow_metadata.node_libraries_referenced] + main_body: list[ast.stmt] = [] - prereq_code = self._generate_workflow_run_prerequisite_code(import_recorder=import_recorder) + prereq_code = self._generate_workflow_run_prerequisite_code( + import_recorder=import_recorder, library_names=library_names + ) main_body.extend(cast("ast.stmt", node) for node in prereq_code) # Generate unique values code AST node @@ -3636,10 +3639,48 @@ def _generate_ensure_flow_context_call( def _generate_workflow_run_prerequisite_code( self, - import_recorder: ImportRecorder, # noqa: ARG002 (kept for symmetry with other _generate_* helpers) + import_recorder: ImportRecorder, + library_names: list[str], ) -> list[ast.AST]: code_blocks: list[ast.AST] = [] + # Emit `await GriptapeNodes.ahandle_request(RegisterLibraryFromFileRequest(...))` once + # per declared library so build_workflow() registers its own dependencies before any + # CreateNodeRequest runs. Without this, running the workflow file as a standalone script + # (uv run workflow.py) would have no libraries registered when nodes are created, since + # LocalWorkflowExecutor's __aenter__ runs after build_workflow() and is gated by + # skip_library_loading=True. perform_discovery_if_not_found=True lets the registration + # find the library JSON via the engine's normal config-driven discovery path. + if library_names: + import_recorder.add_from_import( + "griptape_nodes.retained_mode.events.library_events", "RegisterLibraryFromFileRequest" + ) + for library_name in library_names: + register_call = ast.Expr( + value=ast.Await( + value=ast.Call( + func=ast.Attribute( + value=ast.Name(id="GriptapeNodes", ctx=ast.Load()), + attr="ahandle_request", + ctx=ast.Load(), + ), + args=[ + ast.Call( + func=ast.Name(id="RegisterLibraryFromFileRequest", ctx=ast.Load()), + args=[], + keywords=[ + ast.keyword(arg="library_name", value=ast.Constant(value=library_name)), + ast.keyword(arg="perform_discovery_if_not_found", value=ast.Constant(value=True)), + ], + ) + ], + keywords=[], + ) + ) + ) + ast.fix_missing_locations(register_call) + code_blocks.append(register_call) + # Generate context manager assignment assign_context_manager = ast.Assign( targets=[ast.Name(id="context_manager", ctx=ast.Store())], diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/fixtures/__init__.py b/tests/e2e/fixtures/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/fixtures/echo_library/__init__.py b/tests/e2e/fixtures/echo_library/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/fixtures/echo_library/echo_node.py b/tests/e2e/fixtures/echo_library/echo_node.py new file mode 100644 index 0000000000..9c7e974117 --- /dev/null +++ b/tests/e2e/fixtures/echo_library/echo_node.py @@ -0,0 +1,38 @@ +"""Echo node used by tests/e2e/test_standalone_workflow_execution.py. + +Minimal BaseNode subclass that copies its `text` input to its `result` output. Kept +intentionally tiny so the e2e fixture has zero pip dependencies and registers cleanly +in a subprocess that has only the engine itself on its Python path. +""" + +from __future__ import annotations + +from griptape_nodes.exe_types.core_types import Parameter, ParameterMode +from griptape_nodes.exe_types.node_types import DataNode + + +class EchoNode(DataNode): + def __init__(self, name: str, metadata: dict | None = None) -> None: + super().__init__(name, metadata=metadata) + self.add_parameter( + Parameter( + name="text", + tooltip="Text to echo", + type="str", + default_value="", + allowed_modes={ParameterMode.INPUT, ParameterMode.PROPERTY}, + ) + ) + self.add_parameter( + Parameter( + name="result", + tooltip="Echoed text", + type="str", + default_value="", + allowed_modes={ParameterMode.OUTPUT, ParameterMode.PROPERTY}, + ) + ) + + def process(self) -> None: + text = self.get_parameter_value("text") or "" + self.parameter_output_values["result"] = text diff --git a/tests/e2e/fixtures/echo_library/griptape_nodes_library.json b/tests/e2e/fixtures/echo_library/griptape_nodes_library.json new file mode 100644 index 0000000000..a314d768f4 --- /dev/null +++ b/tests/e2e/fixtures/echo_library/griptape_nodes_library.json @@ -0,0 +1,35 @@ +{ + "name": "Echo Library", + "library_schema_version": "0.7.0", + "metadata": { + "author": "Test Fixture", + "description": "Minimal library used by standalone-workflow execution tests", + "library_version": "0.1.0", + "engine_version": "0.0.0", + "tags": ["test"], + "dependencies": { + "pip_dependencies": [] + } + }, + "categories": [ + { + "test": { + "title": "test", + "description": "Test nodes", + "color": "border-gray-500", + "icon": "Folder" + } + } + ], + "nodes": [ + { + "class_name": "EchoNode", + "file_path": "echo_node.py", + "metadata": { + "category": "test", + "description": "Copies its `text` input to its `result` output", + "display_name": "Echo" + } + } + ] +} diff --git a/tests/e2e/test_standalone_workflow_execution.py b/tests/e2e/test_standalone_workflow_execution.py new file mode 100644 index 0000000000..1a204cc13c --- /dev/null +++ b/tests/e2e/test_standalone_workflow_execution.py @@ -0,0 +1,257 @@ +"""End-to-end coverage for self-executable workflow files. + +Drives the real workflow generator end-to-end: registers a fixture library inside +the test process, builds a flow that uses one of its nodes, serializes the flow +through ``WorkflowManager._generate_workflow_file_content``, writes the emitted +``.py`` to disk, and runs it in a fresh ``python `` subprocess. + +This is the bootstrap path issue #4584 describes: ``LocalWorkflowExecutor`` with +``skip_library_loading=True``, ``build_workflow()`` running before the executor's +``__aenter__``, and no engine-driven preregistration. If the generator stops emitting +``RegisterLibraryFromFileRequest`` calls inside ``build_workflow()``, every +``CreateNodeRequest`` in the subprocess collapses into ``ErrorProxyNode`` and this +test fails. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from griptape_nodes.node_library.workflow_registry import WorkflowMetadata +from griptape_nodes.retained_mode.events.flow_events import ( + CreateFlowRequest, + CreateFlowResultSuccess, + SerializeFlowToCommandsRequest, + SerializeFlowToCommandsResultSuccess, +) +from griptape_nodes.retained_mode.events.library_events import ( + RegisterLibraryFromFileRequest, + RegisterLibraryFromFileResultSuccess, +) +from griptape_nodes.retained_mode.events.node_events import CreateNodeRequest, CreateNodeResultSuccess +from griptape_nodes.retained_mode.events.object_events import ClearAllObjectStateRequest +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes + +FIXTURE_LIBRARY_DIR = Path(__file__).parent / "fixtures" / "echo_library" +FIXTURE_LIBRARY_JSON_TEMPLATE = FIXTURE_LIBRARY_DIR / "griptape_nodes_library.json" +FIXTURE_NODE_FILE = FIXTURE_LIBRARY_DIR / "echo_node.py" + + +def _materialize_library(target_dir: Path) -> Path: + """Copy the on-disk fixture into ``target_dir`` and stamp the current engine version. + + Rewrites the fixture's ``engine_version`` field to the engine's running version so + ``IncompatibleEngineVersionCheck`` never marks the library UNUSABLE on a version + bump. The on-disk copy of the JSON keeps a placeholder version that is never read + by anything except this materializer. + """ + from griptape_nodes.utils.version_utils import engine_version + + target_dir.mkdir(parents=True, exist_ok=True) + schema = json.loads(FIXTURE_LIBRARY_JSON_TEMPLATE.read_text()) + schema["metadata"]["engine_version"] = engine_version + library_json = target_dir / "griptape_nodes_library.json" + library_json.write_text(json.dumps(schema, indent=2)) + (target_dir / FIXTURE_NODE_FILE.name).write_text(FIXTURE_NODE_FILE.read_text()) + return library_json + + +def _write_isolated_config(config_root: Path, *, workspace: Path, library_path: Path) -> None: + """Write an XDG-style config file that registers the fixture library. + + ``RegisterLibraryFromFileRequest(perform_discovery_if_not_found=True)`` resolves + library names through the engine's normal discovery path, which reads from + ``app_events.on_app_initialization_complete.libraries_to_register`` in the user + config. Pointing that at the materialized fixture is enough to let the standalone + workflow find ``Echo Library`` without touching the user's real config. + """ + config_dir = config_root / "griptape_nodes" + config_dir.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "griptape_nodes_config.json" + config_path.write_text( + json.dumps( + { + "workspace_directory": str(workspace), + "log_level": "WARNING", + "app_events": { + "on_app_initialization_complete": { + "libraries_to_register": [str(library_path)], + }, + }, + } + ) + ) + + +def _generate_echo_workflow_source(library_json: Path) -> str: + """Build a flow with one EchoNode and serialize it to a Python module. + + Uses the same path the engine uses when saving a workflow: register the library, + create a flow, drop a node into it, ask FlowManager to serialize, then run the + serialized commands through ``WorkflowManager._generate_workflow_file_content``. + The resulting source is what a user would see saved to disk. + """ + GriptapeNodes.handle_request(ClearAllObjectStateRequest(i_know_what_im_doing=True)) + + register_result = GriptapeNodes.handle_request(RegisterLibraryFromFileRequest(file_path=str(library_json))) + assert isinstance(register_result, RegisterLibraryFromFileResultSuccess), register_result + + GriptapeNodes.ContextManager().push_workflow(workflow_name="echo_e2e_workflow") + + flow_result = GriptapeNodes.handle_request( + CreateFlowRequest(parent_flow_name=None, flow_name="ControlFlow_1", set_as_new_context=False) + ) + assert isinstance(flow_result, CreateFlowResultSuccess), flow_result + + node_result = GriptapeNodes.handle_request( + CreateNodeRequest( + node_type="EchoNode", + specific_library_name="Echo Library", + node_name="Echo_1", + override_parent_flow_name=flow_result.flow_name, + ) + ) + assert isinstance(node_result, CreateNodeResultSuccess), node_result + assert node_result.node_type == "EchoNode", ( + f"Sanity: in-process registration must yield real node, got {node_result.node_type!r}" + ) + + serialize_result = GriptapeNodes.handle_request(SerializeFlowToCommandsRequest(flow_name=flow_result.flow_name)) + assert isinstance(serialize_result, SerializeFlowToCommandsResultSuccess), serialize_result + + metadata = WorkflowMetadata( + name="echo_e2e_workflow", + schema_version=WorkflowMetadata.LATEST_SCHEMA_VERSION, + engine_version_created_with="0.0.0", + node_libraries_referenced=list(serialize_result.serialized_flow_commands.node_dependencies.libraries), + # Skip the executable wrapper; we only need build_workflow to run end-to-end. + # _generate_workflow_execution gates the __main__ block on workflow_shape, so + # workflow_shape=None keeps the file inert at import time. + workflow_shape=None, + ) + return GriptapeNodes.WorkflowManager()._generate_workflow_file_content( + serialized_flow_commands=serialize_result.serialized_flow_commands, + workflow_metadata=metadata, + ) + + +def _wrap_with_runtime_assertions(workflow_source: str) -> str: + """Append a ``__main__`` block that runs build_workflow and prints the resulting node type. + + The generator only emits ``__main__`` for shape-bearing workflows (those with + StartFlow/EndFlow), and the fixture library has neither. Glue on a tiny driver + that mirrors how the generator's ``aexecute_workflow`` invokes build_workflow + inside ``LocalWorkflowExecutor``, then prints the actual node type so the test + process can observe whether the registration step worked. + """ + runtime_block = """ + +import asyncio as _e2e_asyncio +import logging as _e2e_logging + +from griptape_nodes.bootstrap.workflow_executors.local_workflow_executor import ( + LocalWorkflowExecutor as _E2ELocalWorkflowExecutor, +) +from griptape_nodes.drivers.storage.storage_backend import StorageBackend as _E2EStorageBackend +from griptape_nodes.retained_mode.events.flow_events import ( + GetTopLevelFlowRequest as _E2EGetTopLevelFlowRequest, + GetTopLevelFlowResultSuccess as _E2EGetTopLevelFlowResultSuccess, + ListNodesInFlowRequest as _E2EListNodesInFlowRequest, + ListNodesInFlowResultSuccess as _E2EListNodesInFlowResultSuccess, +) + + +async def _e2e_run() -> None: + # Mirror generator-emitted aexecute_workflow ordering: build the graph first, then + # enter the executor. This is the path #4584 describes, where the standalone + # subprocess has no engine bootstrap registering libraries before build_workflow. + await build_workflow() # noqa: F821 - defined by exec'd workflow source above + top_level = await GriptapeNodes.ahandle_request(_E2EGetTopLevelFlowRequest()) # noqa: F821 + if not isinstance(top_level, _E2EGetTopLevelFlowResultSuccess) or top_level.flow_name is None: + raise RuntimeError(f"E2E_FAIL: no top-level flow after build_workflow: {top_level}") + list_nodes = await GriptapeNodes.ahandle_request( # noqa: F821 + _E2EListNodesInFlowRequest(flow_name=top_level.flow_name) + ) + if not isinstance(list_nodes, _E2EListNodesInFlowResultSuccess): + raise RuntimeError(f"E2E_FAIL: could not list nodes: {list_nodes}") + node_manager = GriptapeNodes.NodeManager() # noqa: F821 + for node_name in list_nodes.node_names: + node = node_manager.get_node_by_name(node_name) + print(f"NODE_TYPE name={node_name} type={type(node).__name__}", flush=True) + workflow_executor = _E2ELocalWorkflowExecutor( + storage_backend=_E2EStorageBackend.LOCAL, + skip_library_loading=True, + workflows_to_register=[__file__], + ) + async with workflow_executor: + pass + + +if __name__ == "__main__": + _e2e_logging.basicConfig(level=_e2e_logging.WARNING) + _e2e_asyncio.run(_e2e_run()) +""" + return workflow_source + runtime_block + + +@pytest.mark.skipif( + not FIXTURE_LIBRARY_JSON_TEMPLATE.exists(), + reason=f"Echo Library fixture missing at {FIXTURE_LIBRARY_JSON_TEMPLATE}", +) +def test_standalone_workflow_registers_declared_libraries(tmp_path: Path) -> None: + """A generated workflow run via subprocess must produce real nodes, not proxies. + + Drives the real generator (no hand-crafted source) so a regression that drops + ``RegisterLibraryFromFileRequest`` from emitted ``build_workflow()`` bodies fails + this test, just like the in-process unit test asserts the same contract via AST + inspection plus a recording stub. + """ + workspace = tmp_path / "workspace" + workspace.mkdir() + config_root = tmp_path / "xdg_config" + library_json = _materialize_library(tmp_path / "library") + _write_isolated_config(config_root, workspace=workspace, library_path=library_json) + + # Generator runs in the test process where Echo Library can be registered locally, + # so SerializeFlowToCommandsRequest can build a faithful command list. + workflow_source = _generate_echo_workflow_source(library_json) + runnable_source = _wrap_with_runtime_assertions(workflow_source) + assert "RegisterLibraryFromFileRequest(library_name='Echo Library'" in workflow_source, ( + "build_workflow must emit the registration call; if this assertion fails the unit" + " AST tests for #4584 should fail too" + ) + + workflow_path = tmp_path / "echo_workflow.py" + workflow_path.write_text(runnable_source) + + env = os.environ.copy() + env["XDG_CONFIG_HOME"] = str(config_root) + # Engine bootstrap requires GT_CLOUD_API_KEY to be set; the value never leaves the + # subprocess so a placeholder is fine. + env.setdefault("GT_CLOUD_API_KEY", "fake-test-key-for-bootstrap") + + result = subprocess.run( # noqa: S603 - subprocess input is constructed inside the test + [sys.executable, str(workflow_path)], + env=env, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + diagnostic = ( + f"workflow exit code: {result.returncode}\n--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}" + ) + assert result.returncode == 0, diagnostic + assert "type=EchoNode" in result.stdout, diagnostic + assert "type=ErrorProxyNode" not in result.stdout, diagnostic + # The placeholder substitution warning is the engine's single most reliable signal + # that a library was missing at CreateNode time. If it appears, build_workflow was + # not registering libraries before creating nodes regardless of any other output. + assert "Created Error Proxy" not in result.stderr, diagnostic diff --git a/tests/unit/retained_mode/managers/test_context_manager.py b/tests/unit/retained_mode/managers/test_context_manager.py index 6b57a32809..c39d0b93b5 100644 --- a/tests/unit/retained_mode/managers/test_context_manager.py +++ b/tests/unit/retained_mode/managers/test_context_manager.py @@ -109,6 +109,7 @@ def test_generated_code_uses_file_path_not_workflow_name(self, griptape_nodes: G import_recorder = ImportRecorder() code_blocks = workflow_manager._generate_workflow_run_prerequisite_code( import_recorder=import_recorder, + library_names=[], ) module = ast.Module(body=[n for n in code_blocks if isinstance(n, ast.stmt)], type_ignores=[]) diff --git a/tests/unit/retained_mode/managers/test_workflow_manager.py b/tests/unit/retained_mode/managers/test_workflow_manager.py index 9ded46a6ec..2b19068d57 100644 --- a/tests/unit/retained_mode/managers/test_workflow_manager.py +++ b/tests/unit/retained_mode/managers/test_workflow_manager.py @@ -1323,6 +1323,153 @@ def test_generate_workflow_file_content_prereq_lives_inside_build_workflow( assert "context_manager = GriptapeNodes.ContextManager()" in body_src assert "context_manager.push_workflow(file_path=__file__)" in body_src + def test_generate_workflow_file_content_registers_declared_libraries_in_build_workflow( + self, griptape_nodes: GriptapeNodes + ) -> None: + """build_workflow() must register every library named in node_libraries_referenced. + + Regression test for https://github.com/griptape-ai/griptape-nodes/issues/4584: + when a generated workflow is run as a standalone script via LocalWorkflowExecutor, + no engine-side library bootstrap runs before build_workflow(), so the file itself + must register its declared libraries to avoid every CreateNodeRequest collapsing + into an ErrorProxyNode. + """ + from griptape_nodes.node_library.library_registry import LibraryNameAndVersion + + workflow_manager = griptape_nodes.WorkflowManager() + metadata = WorkflowMetadata( + name="test_workflow", + schema_version=WorkflowMetadata.LATEST_SCHEMA_VERSION, + engine_version_created_with="1.0.0", + node_libraries_referenced=[ + LibraryNameAndVersion(library_name="Griptape Nodes Library", library_version="0.1.0"), + LibraryNameAndVersion(library_name="Other Library", library_version="0.2.0"), + ], + workflow_shape=None, + ) + content = workflow_manager._generate_workflow_file_content( + serialized_flow_commands=self._empty_serialized_flow_commands(), + workflow_metadata=metadata, + ) + + module = ast.parse(content) + + # Each declared library must appear as an awaited RegisterLibraryFromFileRequest inside + # build_workflow(), with perform_discovery_if_not_found=True so the engine can locate + # the library JSON via the standard config-driven discovery path. + build_workflow = next( + node for node in module.body if isinstance(node, ast.AsyncFunctionDef) and node.name == "build_workflow" + ) + body_src = "\n".join(ast.unparse(stmt) for stmt in build_workflow.body) + for library_name in ("Griptape Nodes Library", "Other Library"): + assert ( + f"RegisterLibraryFromFileRequest(library_name='{library_name}', perform_discovery_if_not_found=True)" + ) in body_src, f"build_workflow() must register library {library_name!r}; got body:\n{body_src}" + + # The corresponding import must also be present at module scope, since build_workflow() + # references RegisterLibraryFromFileRequest by name. + top_level_imports = [ + f"{alias.name}" for stmt in module.body if isinstance(stmt, ast.ImportFrom) for alias in stmt.names + ] + assert "RegisterLibraryFromFileRequest" in top_level_imports + + def test_generate_workflow_file_content_omits_register_calls_when_no_libraries_declared( + self, griptape_nodes: GriptapeNodes + ) -> None: + """With an empty node_libraries_referenced list, no register calls are emitted. + + Keeps the import set tight for shape-free workflows that don't actually reference + a library, and makes sure the empty-loop branch in _generate_workflow_run_prerequisite_code + does not regress to emitting stray RegisterLibraryFromFileRequest noise. + """ + content = self._generate(griptape_nodes) + assert "RegisterLibraryFromFileRequest" not in content + + def test_generated_build_workflow_registers_libraries_before_creating_nodes( + self, griptape_nodes: GriptapeNodes + ) -> None: + """build_workflow() must dispatch RegisterLibraryFromFileRequest before any CreateNodeRequest. + + Captures the runtime contract for issue #4584: when a generated workflow runs as a + standalone script (no engine bootstrap, no _ensure_libraries_for_workflow), the file + itself must register every declared library before issuing CreateNodeRequest, or every + node collapses into an ErrorProxyNode. Verified by exec()ing the generated source and + recording the request order via a stub ahandle_request, which is cheap and does not + require a real library on disk. + """ + from griptape_nodes.node_library.library_registry import LibraryNameAndVersion + from griptape_nodes.retained_mode.events.flow_events import CreateFlowRequest as _CreateFlowRequest + from griptape_nodes.retained_mode.events.library_events import RegisterLibraryFromFileRequest + from griptape_nodes.retained_mode.events.node_events import CreateNodeRequest, SerializedNodeCommands + + workflow_manager = griptape_nodes.WorkflowManager() + + flow = SerializedFlowCommands( + flow_initialization_command=_CreateFlowRequest( + parent_flow_name=None, flow_name="ControlFlow_1", set_as_new_context=False, metadata={} + ), + serialized_node_commands=[ + SerializedNodeCommands( + create_node_command=CreateNodeRequest( + node_type="Note", + specific_library_name="Foo Library", + node_name="Note_1", + initial_setup=True, + ), + element_modification_commands=[], + node_dependencies=NodeDependencies(), + ), + ], + serialized_connections=[], + unique_parameter_uuid_to_values={}, + set_parameter_value_commands={}, + set_lock_commands_per_node={}, + sub_flows_commands=[], + node_dependencies=NodeDependencies(), + node_types_used=set(), + ) + metadata = WorkflowMetadata( + name="runtime_order_test", + schema_version=WorkflowMetadata.LATEST_SCHEMA_VERSION, + engine_version_created_with="1.0.0", + node_libraries_referenced=[ + LibraryNameAndVersion(library_name="Foo Library", library_version="0.1.0"), + LibraryNameAndVersion(library_name="Bar Library", library_version="0.2.0"), + ], + workflow_shape=None, + ) + script_source = workflow_manager._generate_workflow_file_content( + serialized_flow_commands=flow, + workflow_metadata=metadata, + ) + + dispatched: list[object] = [] + + original_ahandle = GriptapeNodes.ahandle_request + + async def recording_ahandle_request(request: object) -> object: + dispatched.append(request) + return await original_ahandle(request) # type: ignore[arg-type] + + exec_globals: dict[str, object] = {"__file__": "runtime_order_test.py"} + exec(compile(script_source, "", "exec"), exec_globals) # noqa: S102 + with patch.object(GriptapeNodes, "ahandle_request", side_effect=recording_ahandle_request): + asyncio.run(exec_globals["build_workflow"]()) # type: ignore[operator] + + register_indices = [i for i, req in enumerate(dispatched) if isinstance(req, RegisterLibraryFromFileRequest)] + register_names = {dispatched[i].library_name for i in register_indices} # type: ignore[attr-defined] + create_indices = [i for i, req in enumerate(dispatched) if isinstance(req, CreateNodeRequest)] + + assert register_names == {"Foo Library", "Bar Library"}, ( + f"build_workflow must register every declared library; got {register_names!r}" + ) + assert create_indices, "build_workflow must dispatch the serialized CreateNodeRequest" + assert max(register_indices) < min(create_indices), ( + "every RegisterLibraryFromFileRequest must precede every CreateNodeRequest, otherwise" + " CreateNodeRequest will fall back to ErrorProxyNode in standalone runs;" + f" got order {[type(r).__name__ for r in dispatched]}" + ) + def test_generate_workflow_file_content_empty_build_workflow_has_pass_body( self, griptape_nodes: GriptapeNodes ) -> None: From 34a856d4e85e9b0062ad02aebd64de3e93a5a633 Mon Sep 17 00:00:00 2001 From: Collin Dutter Date: Tue, 19 May 2026 15:06:47 -0700 Subject: [PATCH 05/22] fix: surface library directory path on retryable library failures (#4637) Adds `existing_path` to `UpdateLibraryResultFailure` and `DownloadLibraryResultFailure` and populates it on the uncommitted-changes update failure and the target-already-exists download failure. Lets clients render the dirty/conflicting directory without parsing it out of the human-readable message, which is unreliable for paths containing `:` (e.g. Windows drive letters). --- .../retained_mode/events/library_events.py | 9 ++ .../retained_mode/managers/library_manager.py | 12 ++- .../test_library_manager_directory_paths.py | 100 +++++++++++++++++- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/griptape_nodes/retained_mode/events/library_events.py b/src/griptape_nodes/retained_mode/events/library_events.py index 18e14ca4fc..f0b52d0a2a 100644 --- a/src/griptape_nodes/retained_mode/events/library_events.py +++ b/src/griptape_nodes/retained_mode/events/library_events.py @@ -996,9 +996,14 @@ class UpdateLibraryResultFailure(ResultPayloadFailure): Args: retryable: If True, the operation can be retried with overwrite_existing=True + existing_path: When the failure is caused by uncommitted changes in the library directory, + the absolute path of that directory. Provided as a structured field so clients do not + have to parse it out of the human-readable error message (which is unreliable for paths + containing ``:``, e.g. Windows drive letters). """ retryable: bool = False + existing_path: str | None = None @dataclass @@ -1097,9 +1102,13 @@ class DownloadLibraryResultFailure(ResultPayloadFailure): Args: retryable: If True, the operation can be retried with overwrite_existing=True + existing_path: When the failure is caused by an existing target directory, the absolute + path of that directory. Provided as a structured field so clients do not have to + parse it out of the human-readable error message. """ retryable: bool = False + existing_path: str | None = None @dataclass diff --git a/src/griptape_nodes/retained_mode/managers/library_manager.py b/src/griptape_nodes/retained_mode/managers/library_manager.py index 1c9887e77d..f0b554088d 100644 --- a/src/griptape_nodes/retained_mode/managers/library_manager.py +++ b/src/griptape_nodes/retained_mode/managers/library_manager.py @@ -4512,7 +4512,11 @@ async def update_library_request(self, request: UpdateLibraryRequest) -> ResultP retryable = "uncommitted changes" in error_msg or "unstaged changes" in error_msg details = f"Failed to update Library '{library_name}': {e}" - return UpdateLibraryResultFailure(result_details=details, retryable=retryable) + return UpdateLibraryResultFailure( + result_details=details, + retryable=retryable, + existing_path=str(library_dir) if retryable else None, + ) # Reload library reload_result = await self._reload_library_after_git_operation( @@ -4646,7 +4650,11 @@ async def download_library_request(self, request: DownloadLibraryRequest) -> Res if request.fail_on_exists: # Fail with retryable error for interactive CLI details = f"Cannot download library: target directory already exists at {target_path}" - return DownloadLibraryResultFailure(result_details=details, retryable=True) + return DownloadLibraryResultFailure( + result_details=details, + retryable=True, + existing_path=str(target_path), + ) # Skip cloning since directory already exists, but continue with registration skip_clone = True diff --git a/tests/unit/retained_mode/managers/test_library_manager_directory_paths.py b/tests/unit/retained_mode/managers/test_library_manager_directory_paths.py index 5cddc3afd1..ea91d445cb 100644 --- a/tests/unit/retained_mode/managers/test_library_manager_directory_paths.py +++ b/tests/unit/retained_mode/managers/test_library_manager_directory_paths.py @@ -2,13 +2,18 @@ import asyncio from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from griptape_nodes.retained_mode.events.library_events import DownloadLibraryResultFailure +from griptape_nodes.retained_mode.events.library_events import ( + DownloadLibraryResultFailure, + UpdateLibraryRequest, + UpdateLibraryResultFailure, +) from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.utils.git_utils import GitCloneError +from griptape_nodes.retained_mode.managers.library_manager import LibraryGitOperationContext +from griptape_nodes.utils.git_utils import GitCloneError, GitPullError class TestGetSandboxDirectory: @@ -266,3 +271,92 @@ async def test_not_configured_returns_failure(self, griptape_nodes: GriptapeNode result = await library_manager.download_library_request(request) assert isinstance(result, DownloadLibraryResultFailure) + + @pytest.mark.asyncio + async def test_existing_target_dir_sets_existing_path(self, griptape_nodes: GriptapeNodes) -> None: + """Existing target directory failure carries the path in ``existing_path``. + + When the target directory already exists and fail_on_exists is True, the failure + carries the absolute target path in the structured ``existing_path`` field so clients + can render it without having to parse the human-readable error message (which is + unreliable for paths containing ``:``, e.g. Windows drive letters). + """ + library_manager = griptape_nodes.LibraryManager() + config_mgr = MagicMock() + config_mgr.get_config_value.return_value = "/opt/libraries" + config_mgr.workspace_path = Path("/workspace") + + request = MagicMock() + request.git_url = "https://github.com/user/repo.git" + request.branch_tag_commit = None + request.target_directory_name = "repo" + request.download_directory = None + request.overwrite_existing = False + request.fail_on_exists = True + + with ( + patch.object(GriptapeNodes, "ConfigManager", return_value=config_mgr), + patch( + "griptape_nodes.retained_mode.managers.library_manager.resolve_workspace_path", + return_value=Path("/opt/libraries"), + ), + patch( + "griptape_nodes.retained_mode.managers.library_manager.normalize_github_url", + return_value="https://github.com/user/repo.git", + ), + patch("anyio.Path.mkdir"), + patch("anyio.Path.exists", return_value=True), + ): + result = await library_manager.download_library_request(request) + + assert isinstance(result, DownloadLibraryResultFailure) + assert result.retryable is True + assert result.existing_path == str(Path("/opt/libraries/repo")) + + +class TestUpdateLibraryRequestExistingPath: + """Test update_library_request reports the dirty library directory in ``existing_path``.""" + + @pytest.mark.asyncio + async def test_uncommitted_changes_sets_existing_path(self, griptape_nodes: GriptapeNodes) -> None: + """Uncommitted-changes update failure carries the library directory in ``existing_path``. + + Without this, clients on Windows cannot recover the path from the error message because + the drive-letter colon collides with the ``: `` separator in the + human-readable message. + """ + library_manager = griptape_nodes.LibraryManager() + library_dir = Path("/var/lib/test_lib") + + validation_context = LibraryGitOperationContext( + library=MagicMock(), + old_version="1.0.0", + library_file_path=str(library_dir / "griptape_nodes_library.json"), + library_dir=library_dir, + ) + + with ( + patch.object( + library_manager, + "_validate_and_prepare_library_for_git_operation", + new=AsyncMock(return_value=validation_context), + ), + patch( + "griptape_nodes.retained_mode.managers.library_manager.is_monorepo", + return_value=False, + ), + patch( + "griptape_nodes.retained_mode.managers.library_manager.update_library_git", + side_effect=GitPullError( + f"Cannot update library at {library_dir}: You have uncommitted changes. " + "Use overwrite_existing=True to discard them." + ), + ), + ): + result = await library_manager.update_library_request( + UpdateLibraryRequest(library_name="test_lib", overwrite_existing=False) + ) + + assert isinstance(result, UpdateLibraryResultFailure) + assert result.retryable is True + assert result.existing_path == str(library_dir) From 6f59b11e1307cb54f6641a20543403db57428443 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 28 May 2026 14:22:05 +0100 Subject: [PATCH 06/22] feat: implement ProjectOutputParameter base class and refactor output parameters --- .../project_directory_parameter.py | 185 +++----------- .../project_file_parameter.py | 191 +++----------- .../project_image_sequence_parameter.py | 180 +++---------- .../project_output_parameter.py | 236 ++++++++++++++++++ 4 files changed, 336 insertions(+), 456 deletions(-) create mode 100644 src/griptape_nodes/exe_types/param_components/project_output_parameter.py diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index 93625442aa..2d9ff0104a 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -3,14 +3,11 @@ import logging from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter from griptape_nodes.files.directory import DirectoryDestination from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY -from griptape_nodes.retained_mode.events.connection_events import ( - ListConnectionsForNodeRequest, - ListConnectionsForNodeResultSuccess, -) from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy from griptape_nodes.retained_mode.events.project_events import ( GetSituationRequest, @@ -18,8 +15,6 @@ MacroPath, ) from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.retained_mode.retained_mode import RetainedMode -from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload from griptape_nodes.traits.file_system_picker import FileSystemPicker logger = logging.getLogger("griptape_nodes") @@ -34,7 +29,7 @@ class DirectoryDestinationProvider: def directory_destination(self) -> DirectoryDestination | None: ... -class ProjectDirectoryParameter: +class ProjectDirectoryParameter(ProjectOutputParameter): """Parameter component for project-aware directory creation. Adds a directory name parameter to a node that, when processed, returns a @@ -67,28 +62,33 @@ def __init__( # noqa: PLR0913 allowed_modes: set[ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: - """Initialize with situation context. + super().__init__( + node, + name, + default_value=default_dirname, + situation=situation, + allowed_modes=allowed_modes, + ui_options=ui_options, + ) - Args: - node: Parent node instance - name: Parameter name - default_dirname: Default directory name if parameter is empty - situation: Situation name (default: "save_output_directory") - allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) - ui_options: Optional UI options to pass to the generated parameter - """ - self._node = node - self._name = name - self._situation_name = situation - self._default_dirname = default_dirname - self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} - self._ui_options = ui_options - - def add_parameter(self) -> None: - """Create and add the directory name parameter to the node.""" - tooltip = f"Output directory name (uses '{self._situation_name}' situation template)" - - traits: set = { + @property + def _settings_node_type(self) -> str: + return "DirectoryOutputSettings" + + @property + def _settings_value_param_name(self) -> str: + return "dirname" + + @property + def _settings_source_param_name(self) -> str: + return "directory_destination" + + @property + def _parameter_output_type(self) -> str: + return "Directory" + + def _make_parameter_traits(self) -> set: + return { FileSystemPicker( allow_files=False, allow_directories=True, @@ -96,32 +96,6 @@ def add_parameter(self) -> None: ) } - if ParameterMode.INPUT in self._allowed_modes: - traits.add( - Button( - icon="cog", - size="icon", - variant="secondary", - tooltip="Create and connect a DirectoryOutputSettings node", - on_click=self._on_configure_button_clicked, - ) - ) - - parameter = Parameter( - name=self._name, - type="str", - default_value=self._default_dirname, - allowed_modes=self._allowed_modes, - tooltip=tooltip, - input_types=["str"], - output_type="Directory", - traits=traits, - ui_options=self._ui_options, - ) - parameter.on_incoming_connection_removed.append(self._reset_to_default) - - self._node.add_parameter(parameter) - def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: """Build a DirectoryDestination from the parameter's current value. @@ -139,109 +113,20 @@ def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: Raises: ValueError: If an upstream DirectoryDestinationProvider returns None. """ - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - for conn in result.incoming_connections: - if conn.target_parameter_name == self._name: - source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) - if isinstance(source_node, DirectoryDestinationProvider): - dir_dest = source_node.directory_destination - if dir_dest is None: - msg = ( - f"Attempted to build directory destination for {self._node.name}.{self._name}. " - f"Failed because upstream node '{conn.source_node_name}' provides a " - f"DirectoryDestination but returned None (likely missing a directory name)." - ) - raise ValueError(msg) - return dir_dest + upstream = self._get_upstream_destination( + DirectoryDestinationProvider, "directory_destination", "DirectoryDestination" + ) + if upstream is not None: + return upstream # type: ignore[return-value] value = self._node.get_parameter_value(self._name) - dirname = value if isinstance(value, str) and value else self._default_dirname + dirname = value if isinstance(value, str) and value else self._default_value if "node_name" not in extra_vars: extra_vars["node_name"] = self._node.name return _build_directory_destination_from_situation(dirname, self._situation_name, **extra_vars) - def _reset_to_default( - self, - parameter: Parameter, # noqa: ARG002 - source_node_name: str, # noqa: ARG002 - source_parameter_name: str, # noqa: ARG002 - ) -> None: - self._node.set_parameter_value(self._name, self._default_dirname) - self._node.publish_update_to_parameter(self._name, self._default_dirname) - - def _on_configure_button_clicked( - self, - button: Button, # noqa: ARG002 - button_details: ButtonDetailsMessagePayload, - ) -> NodeMessageResult: - """Create and connect a DirectoryOutputSettings node to this parameter.""" - node_name = self._node.name - - has_incoming = False - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) - - if has_incoming: - return NodeMessageResult( - success=False, - details=f"{node_name}: {self._name} parameter already has an incoming connection", - response=button_details, - altered_workflow_state=False, - ) - - create_result = RetainedMode.create_node_relative_to( - reference_node_name=node_name, - new_node_type="DirectoryOutputSettings", - offset_side="left", - offset_x=-750, - offset_y=0, - lock=False, - ) - - if not isinstance(create_result, str): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to create DirectoryOutputSettings node", - response=button_details, - altered_workflow_state=False, - ) - - configure_node_name = create_result - - configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) - if configure_node is not None: - configure_node.set_parameter_value("situation", self._situation_name) - configure_node.publish_update_to_parameter("situation", self._situation_name) - - current_dirname = self._node.get_parameter_value(self._name) - if isinstance(current_dirname, str) and current_dirname: - configure_node.set_parameter_value("dirname", current_dirname) - configure_node.publish_update_to_parameter("dirname", current_dirname) - - connection_result = RetainedMode.connect( - source=f"{configure_node_name}.directory_destination", - destination=f"{node_name}.{self._name}", - ) - - if not connection_result.succeeded(): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to connect {configure_node_name}.directory_destination to {self._name}", - response=button_details, - altered_workflow_state=True, - ) - - return NodeMessageResult( - success=True, - details=f"{node_name}: Created and connected {configure_node_name}", - response=button_details, - altered_workflow_state=True, - ) - def _build_directory_destination_from_situation( dirname: str, diff --git a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py index ac338dd715..f4f3da8aab 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py @@ -1,24 +1,14 @@ """ProjectFileParameter - parameter component for project-aware file saving.""" -import logging - -from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter from griptape_nodes.files.file import FileDestination, FileDestinationProvider from griptape_nodes.files.project_file import ProjectFileDestination -from griptape_nodes.retained_mode.events.connection_events import ( - ListConnectionsForNodeRequest, - ListConnectionsForNodeResultSuccess, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.retained_mode.retained_mode import RetainedMode -from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload from griptape_nodes.traits.file_system_picker import FileSystemPicker -logger = logging.getLogger("griptape_nodes") - -class ProjectFileParameter: +class ProjectFileParameter(ProjectOutputParameter): """Parameter component for project-aware file saving. Adds a file path parameter to a node that, when processed, returns a @@ -50,28 +40,33 @@ def __init__( # noqa: PLR0913 allowed_modes: set[ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: - """Initialize with situation context. + super().__init__( + node, + name, + default_value=default_filename, + situation=situation, + allowed_modes=allowed_modes, + ui_options=ui_options, + ) - Args: - node: Parent node instance - name: Parameter name - default_filename: Default filename if parameter is empty - situation: Situation name (default: "save_node_output") - allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) - ui_options: Optional UI options to pass to the generated parameter - """ - self._node = node - self._name = name - self._situation_name = situation - self._default_filename = default_filename - self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} - self._ui_options = ui_options - - def add_parameter(self) -> None: - """Create and add the file path parameter to the node.""" - tooltip = f"Output filename (uses '{self._situation_name}' situation template)" - - traits: set = { + @property + def _settings_node_type(self) -> str: + return "FileOutputSettings" + + @property + def _settings_value_param_name(self) -> str: + return "filename" + + @property + def _settings_source_param_name(self) -> str: + return "file_destination" + + @property + def _parameter_output_type(self) -> str: + return "str" + + def _make_parameter_traits(self) -> set: + return { FileSystemPicker( allow_files=True, allow_directories=False, @@ -79,37 +74,11 @@ def add_parameter(self) -> None: ) } - if ParameterMode.INPUT in self._allowed_modes: - traits.add( - Button( - icon="cog", - size="icon", - variant="secondary", - tooltip="Create and connect a FileOutputSettings node", - on_click=self._on_configure_button_clicked, - ) - ) - - parameter = Parameter( - name=self._name, - type="str", - default_value=self._default_filename, - allowed_modes=self._allowed_modes, - tooltip=tooltip, - input_types=["str"], - output_type="str", - traits=traits, - ui_options=self._ui_options, - ) - parameter.on_incoming_connection_removed.append(self._reset_to_default) - - self._node.add_parameter(parameter) - def build_file(self, **extra_vars: str | int) -> FileDestination: """Build a FileDestination with a MacroPath from the parameter's current value. If an upstream node implements FileDestinationProvider (e.g., FileOutputSettings), - its FileDestination is retrieved directly without deserializing from the wire. + its FileDestination is retrieved directly without deserialising from the wire. An upstream provider that returns None (misconfigured) raises instead of silently falling back to the default situation, since that fallback hides user intent. @@ -126,28 +95,12 @@ def build_file(self, **extra_vars: str | int) -> FileDestination: Raises: ValueError: If an upstream FileDestinationProvider is connected but returns None. """ - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - for conn in result.incoming_connections: - if conn.target_parameter_name == self._name: - source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) - if isinstance(source_node, FileDestinationProvider): - file_dest = source_node.file_destination - if file_dest is None: - msg = ( - f"Attempted to build file destination for {self._node.name}.{self._name}. " - f"Failed because upstream node '{conn.source_node_name}' provides a " - f"FileDestination but returned None (likely missing a filename)." - ) - raise ValueError(msg) - return file_dest + upstream = self._get_upstream_destination(FileDestinationProvider, "file_destination", "FileDestination") + if upstream is not None: + return upstream # type: ignore[return-value] value = self._node.get_parameter_value(self._name) - - if isinstance(value, str) and value: - filename = value - else: - filename = self._default_filename + filename = value if isinstance(value, str) and value else self._default_value if "node_name" not in extra_vars: extra_vars["node_name"] = self._node.name @@ -157,79 +110,3 @@ def build_file(self, **extra_vars: str | int) -> FileDestination: self._situation_name, **extra_vars, ) - - def _reset_to_default(self, parameter: Parameter, source_node_name: str, source_parameter_name: str) -> None: # noqa: ARG002 - self._node.set_parameter_value(self._name, self._default_filename) - self._node.publish_update_to_parameter(self._name, self._default_filename) - - def _on_configure_button_clicked( - self, - button: Button, # noqa: ARG002 - button_details: ButtonDetailsMessagePayload, - ) -> NodeMessageResult: - """Create and connect a FileOutputSettings node to this parameter.""" - node_name = self._node.name - - has_incoming = False - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) - - if has_incoming: - return NodeMessageResult( - success=False, - details=f"{node_name}: {self._name} parameter already has an incoming connection", - response=button_details, - altered_workflow_state=False, - ) - - # TODO: https://github.com/griptape-ai/griptape-nodes/issues/4097 - # Replace with a non-RM utility for creating sibling nodes relative to a given node. - create_result = RetainedMode.create_node_relative_to( - reference_node_name=node_name, - new_node_type="FileOutputSettings", - offset_side="left", - offset_x=-750, - offset_y=0, - lock=False, - ) - - if not isinstance(create_result, str): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to create FileOutputSettings node", - response=button_details, - altered_workflow_state=False, - ) - - configure_node_name = create_result - - configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) - if configure_node is not None: - configure_node.set_parameter_value("situation", self._situation_name) - configure_node.publish_update_to_parameter("situation", self._situation_name) - - current_filename = self._node.get_parameter_value(self._name) - if isinstance(current_filename, str) and current_filename: - configure_node.set_parameter_value("filename", current_filename) - configure_node.publish_update_to_parameter("filename", current_filename) - - connection_result = RetainedMode.connect( - source=f"{configure_node_name}.file_destination", - destination=f"{node_name}.{self._name}", - ) - - if not connection_result.succeeded(): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to connect {configure_node_name}.file_destination to {self._name}", - response=button_details, - altered_workflow_state=True, - ) - - return NodeMessageResult( - success=True, - details=f"{node_name}: Created and connected {configure_node_name}", - response=button_details, - altered_workflow_state=True, - ) diff --git a/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py index 8cf5190995..f44d7d9413 100644 --- a/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py @@ -3,8 +3,9 @@ import logging from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter from griptape_nodes.files.image_sequence import ( ImageSequenceDestination, build_versioned_sequence_destination, @@ -12,10 +13,6 @@ ) from griptape_nodes.files.path_utils import FilenameParts from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY -from griptape_nodes.retained_mode.events.connection_events import ( - ListConnectionsForNodeRequest, - ListConnectionsForNodeResultSuccess, -) from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy from griptape_nodes.retained_mode.events.project_events import ( GetSituationRequest, @@ -23,8 +20,6 @@ MacroPath, ) from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.retained_mode.retained_mode import RetainedMode -from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload logger = logging.getLogger("griptape_nodes") @@ -40,7 +35,7 @@ class ImageSequenceDestinationProvider: def image_sequence_destination(self) -> ImageSequenceDestination | None: ... -class ProjectImageSequenceParameter: +class ProjectImageSequenceParameter(ProjectOutputParameter): """Parameter component for project-aware image sequence output. Adds a filename-pattern parameter to a node that, when processed, returns an @@ -79,54 +74,30 @@ def __init__( # noqa: PLR0913 allowed_modes: set[ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: - """Initialize with situation context. - - Args: - node: Parent node instance - name: Parameter name - default_filename: Default filename (e.g., ``"frame.exr"`` or ``"frame_####.exr"``) - situation: Situation name (default: "save_image_sequence_frame") - allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) - ui_options: Optional UI options to pass to the generated parameter - """ - self._node = node - self._name = name - self._situation_name = situation - self._default_filename = default_filename - self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} - self._ui_options = ui_options - - def add_parameter(self) -> None: - """Create and add the image sequence pattern parameter to the node.""" - tooltip = f"Output frame filename (uses '{self._situation_name}' situation template)" - - traits: set = set() - - if ParameterMode.INPUT in self._allowed_modes: - traits.add( - Button( - icon="cog", - size="icon", - variant="secondary", - tooltip="Create and connect an ImageSequenceSettings node", - on_click=self._on_configure_button_clicked, - ) - ) - - parameter = Parameter( - name=self._name, - type="str", - default_value=self._default_filename, - allowed_modes=self._allowed_modes, - tooltip=tooltip, - input_types=["str"], - output_type="ImageSequence", - traits=traits, - ui_options=self._ui_options, + super().__init__( + node, + name, + default_value=default_filename, + situation=situation, + allowed_modes=allowed_modes, + ui_options=ui_options, ) - parameter.on_incoming_connection_removed.append(self._reset_to_default) - self._node.add_parameter(parameter) + @property + def _settings_node_type(self) -> str: + return "ImageSequenceSettings" + + @property + def _settings_value_param_name(self) -> str: + return "filename" + + @property + def _settings_source_param_name(self) -> str: + return "sequence_destination" + + @property + def _parameter_output_type(self) -> str: + return "ImageSequence" def build_sequence(self, **extra_vars: str | int) -> ImageSequenceDestination: """Build an ImageSequenceDestination from the parameter's current value. @@ -146,109 +117,20 @@ def build_sequence(self, **extra_vars: str | int) -> ImageSequenceDestination: ValueError: If an upstream ImageSequenceDestinationProvider returns None. ImageSequenceError: If no available version index can be found. """ - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - for conn in result.incoming_connections: - if conn.target_parameter_name == self._name: - source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) - if isinstance(source_node, ImageSequenceDestinationProvider): - seq_dest = source_node.image_sequence_destination - if seq_dest is None: - msg = ( - f"Attempted to build image sequence destination for {self._node.name}.{self._name}. " - f"Failed because upstream node '{conn.source_node_name}' provides an " - f"ImageSequenceDestination but returned None (likely missing a filename)." - ) - raise ValueError(msg) - return seq_dest + upstream = self._get_upstream_destination( + ImageSequenceDestinationProvider, "image_sequence_destination", "ImageSequenceDestination" + ) + if upstream is not None: + return upstream # type: ignore[return-value] value = self._node.get_parameter_value(self._name) - filename = value if isinstance(value, str) and value else self._default_filename + filename = value if isinstance(value, str) and value else self._default_value if "node_name" not in extra_vars: extra_vars["node_name"] = self._node.name return _build_sequence_destination_from_situation(filename, self._situation_name, **extra_vars) - def _reset_to_default( - self, - parameter: Parameter, # noqa: ARG002 - source_node_name: str, # noqa: ARG002 - source_parameter_name: str, # noqa: ARG002 - ) -> None: - self._node.set_parameter_value(self._name, self._default_filename) - self._node.publish_update_to_parameter(self._name, self._default_filename) - - def _on_configure_button_clicked( - self, - button: Button, # noqa: ARG002 - button_details: ButtonDetailsMessagePayload, - ) -> NodeMessageResult: - """Create and connect an ImageSequenceSettings node to this parameter.""" - node_name = self._node.name - - has_incoming = False - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) - - if has_incoming: - return NodeMessageResult( - success=False, - details=f"{node_name}: {self._name} parameter already has an incoming connection", - response=button_details, - altered_workflow_state=False, - ) - - create_result = RetainedMode.create_node_relative_to( - reference_node_name=node_name, - new_node_type="ImageSequenceSettings", - offset_side="left", - offset_x=-750, - offset_y=0, - lock=False, - ) - - if not isinstance(create_result, str): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to create ImageSequenceSettings node", - response=button_details, - altered_workflow_state=False, - ) - - configure_node_name = create_result - - configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) - if configure_node is not None: - configure_node.set_parameter_value("situation", self._situation_name) - configure_node.publish_update_to_parameter("situation", self._situation_name) - - current_filename = self._node.get_parameter_value(self._name) - if isinstance(current_filename, str) and current_filename: - configure_node.set_parameter_value("filename", current_filename) - configure_node.publish_update_to_parameter("filename", current_filename) - - connection_result = RetainedMode.connect( - source=f"{configure_node_name}.sequence_destination", - destination=f"{node_name}.{self._name}", - ) - - if not connection_result.succeeded(): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to connect {configure_node_name}.sequence_destination to {self._name}", - response=button_details, - altered_workflow_state=True, - ) - - return NodeMessageResult( - success=True, - details=f"{node_name}: Created and connected {configure_node_name}", - response=button_details, - altered_workflow_state=True, - ) - def _build_sequence_destination_from_situation( filename: str, diff --git a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py new file mode 100644 index 0000000000..d61d6ca12d --- /dev/null +++ b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py @@ -0,0 +1,236 @@ +"""ProjectOutputParameter - shared base class for project-aware output parameter components.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.retained_mode.events.connection_events import ( + ListConnectionsForNodeRequest, + ListConnectionsForNodeResultSuccess, +) +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes +from griptape_nodes.retained_mode.retained_mode import RetainedMode +from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload + +if TYPE_CHECKING: + from griptape_nodes.exe_types.node_types import BaseNode + +logger = logging.getLogger("griptape_nodes") + + +class ProjectOutputParameter(ABC): + """Shared base for project-aware output parameter components. + + Handles the cog-button pattern (create + connect a settings node) and + upstream provider resolution, which are identical across all output types. + Subclasses supply the output-type-specific behaviour via abstract properties + and implement the ``build_xxx()`` method that returns the concrete destination. + """ + + def __init__( # noqa: PLR0913 + self, + node: BaseNode, + name: str, + *, + default_value: str, + situation: str, + allowed_modes: set[ParameterMode] | None = None, + ui_options: dict | None = None, + ) -> None: + """Initialise with situation context. + + Args: + node: Parent node instance. + name: Parameter name. + default_value: Default value when the parameter is empty. + situation: Situation name used to build the output path macro. + allowed_modes: Allowed parameter modes (default: INPUT, PROPERTY). + ui_options: Optional UI options forwarded to the generated parameter. + """ + self._node = node + self._name = name + self._situation_name = situation + self._default_value = default_value + self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} + self._ui_options = ui_options + + # ---- Abstract pieces each subclass must supply ---- + + @property + @abstractmethod + def _settings_node_type(self) -> str: + """Node type to create when the cog button is clicked (e.g. 'FileOutputSettings').""" + + @property + @abstractmethod + def _settings_value_param_name(self) -> str: + """Parameter name on the settings node that holds the filename/dirname (e.g. 'filename').""" + + @property + @abstractmethod + def _settings_source_param_name(self) -> str: + """Output parameter on the settings node to wire to this parameter (e.g. 'file_destination').""" + + @property + @abstractmethod + def _parameter_output_type(self) -> str: + """The output_type string for the generated parameter (e.g. 'str', 'Directory').""" + + # ---- Overridable hooks ---- + + def _make_parameter_traits(self) -> set: + """Return additional traits for the parameter (e.g. a FileSystemPicker). + + Returns an empty set by default; subclasses override to add pickers. + """ + return set() + + # ---- Shared concrete logic ---- + + def add_parameter(self) -> None: + """Create and add the output parameter to the node.""" + tooltip = f"Output path (uses '{self._situation_name}' situation template)" + + traits = self._make_parameter_traits() + if ParameterMode.INPUT in self._allowed_modes: + traits.add( + Button( + icon="cog", + size="icon", + variant="secondary", + tooltip=f"Create and connect a {self._settings_node_type} node", + on_click=self._on_configure_button_clicked, + ) + ) + + parameter = Parameter( + name=self._name, + type="str", + default_value=self._default_value, + allowed_modes=self._allowed_modes, + tooltip=tooltip, + input_types=["str"], + output_type=self._parameter_output_type, + traits=traits, + ui_options=self._ui_options, + ) + parameter.on_incoming_connection_removed.append(self._reset_to_default) + + self._node.add_parameter(parameter) + + def _reset_to_default( + self, + parameter: Parameter, # noqa: ARG002 + source_node_name: str, # noqa: ARG002 + source_parameter_name: str, # noqa: ARG002 + ) -> None: + self._node.set_parameter_value(self._name, self._default_value) + self._node.publish_update_to_parameter(self._name, self._default_value) + + def _get_upstream_destination( + self, + provider_type: type, + destination_attr: str, + destination_type_name: str, + ) -> object | None: + """Return the upstream provider's destination, or None if no provider is connected. + + Raises: + ValueError: If a provider is connected but returns None. + """ + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) + if not isinstance(result, ListConnectionsForNodeResultSuccess): + return None + + for conn in result.incoming_connections: + if conn.target_parameter_name != self._name: + continue + source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) + if not isinstance(source_node, provider_type): + continue + destination = getattr(source_node, destination_attr) + if destination is None: + msg = ( + f"Attempted to build {destination_type_name} for {self._node.name}.{self._name}. " + f"Failed because upstream node '{conn.source_node_name}' returned None " + f"(likely missing a filename or path)." + ) + raise ValueError(msg) + return destination + + return None + + def _on_configure_button_clicked( + self, + button: Button, # noqa: ARG002 + button_details: ButtonDetailsMessagePayload, + ) -> NodeMessageResult: + """Create and connect the appropriate settings node to this parameter.""" + node_name = self._node.name + + has_incoming = False + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) + if isinstance(result, ListConnectionsForNodeResultSuccess): + has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) + + if has_incoming: + return NodeMessageResult( + success=False, + details=f"{node_name}: {self._name} parameter already has an incoming connection", + response=button_details, + altered_workflow_state=False, + ) + + # TODO: https://github.com/griptape-ai/griptape-nodes/issues/4097 + # Replace with a non-RM utility for creating sibling nodes relative to a given node. + create_result = RetainedMode.create_node_relative_to( + reference_node_name=node_name, + new_node_type=self._settings_node_type, + offset_side="left", + offset_x=-750, + offset_y=0, + lock=False, + ) + + if not isinstance(create_result, str): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to create {self._settings_node_type} node", + response=button_details, + altered_workflow_state=False, + ) + + configure_node_name = create_result + + configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) + if configure_node is not None: + configure_node.set_parameter_value("situation", self._situation_name) + configure_node.publish_update_to_parameter("situation", self._situation_name) + + current_value = self._node.get_parameter_value(self._name) + if isinstance(current_value, str) and current_value: + configure_node.set_parameter_value(self._settings_value_param_name, current_value) + configure_node.publish_update_to_parameter(self._settings_value_param_name, current_value) + + connection_result = RetainedMode.connect( + source=f"{configure_node_name}.{self._settings_source_param_name}", + destination=f"{node_name}.{self._name}", + ) + + if not connection_result.succeeded(): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to connect {configure_node_name}.{self._settings_source_param_name} to {self._name}", + response=button_details, + altered_workflow_state=True, + ) + + return NodeMessageResult( + success=True, + details=f"{node_name}: Created and connected {configure_node_name}", + response=button_details, + altered_workflow_state=True, + ) From b5b3c0b074f8b47e2b53efeb3821f7238017d297 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 28 May 2026 14:22:05 +0100 Subject: [PATCH 07/22] feat: add FileSequence and ProjectFileSequenceParameter for file sequence output --- .../default_project_template.py | 8 +- .../project_directory_parameter.py | 185 ++--------- .../project_file_parameter.py | 191 ++--------- .../project_file_sequence_parameter.py | 181 +++++++++++ .../project_image_sequence_parameter.py | 299 ------------------ .../project_output_parameter.py | 236 ++++++++++++++ src/griptape_nodes/files/__init__.py | 4 +- .../{image_sequence.py => file_sequence.py} | 160 +++++----- ...> test_project_file_sequence_parameter.py} | 62 ++-- ...mage_sequence.py => test_file_sequence.py} | 297 +++++++++-------- 10 files changed, 751 insertions(+), 872 deletions(-) create mode 100644 src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py delete mode 100644 src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py create mode 100644 src/griptape_nodes/exe_types/param_components/project_output_parameter.py rename src/griptape_nodes/files/{image_sequence.py => file_sequence.py} (54%) rename tests/unit/exe_types/param_components/{test_project_image_sequence_parameter.py => test_project_file_sequence_parameter.py} (79%) rename tests/unit/files/{test_image_sequence.py => test_file_sequence.py} (51%) diff --git a/src/griptape_nodes/common/project_templates/default_project_template.py b/src/griptape_nodes/common/project_templates/default_project_template.py index 2572233b37..e92ba60738 100644 --- a/src/griptape_nodes/common/project_templates/default_project_template.py +++ b/src/griptape_nodes/common/project_templates/default_project_template.py @@ -151,10 +151,10 @@ ), fallback=None, ), - "save_image_sequence_frame": SituationTemplate( - name="save_image_sequence_frame", - description="Node writes an image sequence; version in directory and frame filenames", - macro="{outputs}/{sub_dirs?:/}{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{frame:04}.{file_extension}", + "save_file_sequence_entry": SituationTemplate( + name="save_file_sequence_entry", + description="Node writes a file sequence; version in directory and entry filenames", + macro="{outputs}/{sub_dirs?:/}{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{entry:04}.{file_extension}", policy=SituationPolicy( on_collision=SituationFilePolicy.CREATE_NEW, create_dirs=True, diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index 93625442aa..2d9ff0104a 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -3,14 +3,11 @@ import logging from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter from griptape_nodes.files.directory import DirectoryDestination from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY -from griptape_nodes.retained_mode.events.connection_events import ( - ListConnectionsForNodeRequest, - ListConnectionsForNodeResultSuccess, -) from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy from griptape_nodes.retained_mode.events.project_events import ( GetSituationRequest, @@ -18,8 +15,6 @@ MacroPath, ) from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.retained_mode.retained_mode import RetainedMode -from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload from griptape_nodes.traits.file_system_picker import FileSystemPicker logger = logging.getLogger("griptape_nodes") @@ -34,7 +29,7 @@ class DirectoryDestinationProvider: def directory_destination(self) -> DirectoryDestination | None: ... -class ProjectDirectoryParameter: +class ProjectDirectoryParameter(ProjectOutputParameter): """Parameter component for project-aware directory creation. Adds a directory name parameter to a node that, when processed, returns a @@ -67,28 +62,33 @@ def __init__( # noqa: PLR0913 allowed_modes: set[ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: - """Initialize with situation context. + super().__init__( + node, + name, + default_value=default_dirname, + situation=situation, + allowed_modes=allowed_modes, + ui_options=ui_options, + ) - Args: - node: Parent node instance - name: Parameter name - default_dirname: Default directory name if parameter is empty - situation: Situation name (default: "save_output_directory") - allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) - ui_options: Optional UI options to pass to the generated parameter - """ - self._node = node - self._name = name - self._situation_name = situation - self._default_dirname = default_dirname - self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} - self._ui_options = ui_options - - def add_parameter(self) -> None: - """Create and add the directory name parameter to the node.""" - tooltip = f"Output directory name (uses '{self._situation_name}' situation template)" - - traits: set = { + @property + def _settings_node_type(self) -> str: + return "DirectoryOutputSettings" + + @property + def _settings_value_param_name(self) -> str: + return "dirname" + + @property + def _settings_source_param_name(self) -> str: + return "directory_destination" + + @property + def _parameter_output_type(self) -> str: + return "Directory" + + def _make_parameter_traits(self) -> set: + return { FileSystemPicker( allow_files=False, allow_directories=True, @@ -96,32 +96,6 @@ def add_parameter(self) -> None: ) } - if ParameterMode.INPUT in self._allowed_modes: - traits.add( - Button( - icon="cog", - size="icon", - variant="secondary", - tooltip="Create and connect a DirectoryOutputSettings node", - on_click=self._on_configure_button_clicked, - ) - ) - - parameter = Parameter( - name=self._name, - type="str", - default_value=self._default_dirname, - allowed_modes=self._allowed_modes, - tooltip=tooltip, - input_types=["str"], - output_type="Directory", - traits=traits, - ui_options=self._ui_options, - ) - parameter.on_incoming_connection_removed.append(self._reset_to_default) - - self._node.add_parameter(parameter) - def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: """Build a DirectoryDestination from the parameter's current value. @@ -139,109 +113,20 @@ def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: Raises: ValueError: If an upstream DirectoryDestinationProvider returns None. """ - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - for conn in result.incoming_connections: - if conn.target_parameter_name == self._name: - source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) - if isinstance(source_node, DirectoryDestinationProvider): - dir_dest = source_node.directory_destination - if dir_dest is None: - msg = ( - f"Attempted to build directory destination for {self._node.name}.{self._name}. " - f"Failed because upstream node '{conn.source_node_name}' provides a " - f"DirectoryDestination but returned None (likely missing a directory name)." - ) - raise ValueError(msg) - return dir_dest + upstream = self._get_upstream_destination( + DirectoryDestinationProvider, "directory_destination", "DirectoryDestination" + ) + if upstream is not None: + return upstream # type: ignore[return-value] value = self._node.get_parameter_value(self._name) - dirname = value if isinstance(value, str) and value else self._default_dirname + dirname = value if isinstance(value, str) and value else self._default_value if "node_name" not in extra_vars: extra_vars["node_name"] = self._node.name return _build_directory_destination_from_situation(dirname, self._situation_name, **extra_vars) - def _reset_to_default( - self, - parameter: Parameter, # noqa: ARG002 - source_node_name: str, # noqa: ARG002 - source_parameter_name: str, # noqa: ARG002 - ) -> None: - self._node.set_parameter_value(self._name, self._default_dirname) - self._node.publish_update_to_parameter(self._name, self._default_dirname) - - def _on_configure_button_clicked( - self, - button: Button, # noqa: ARG002 - button_details: ButtonDetailsMessagePayload, - ) -> NodeMessageResult: - """Create and connect a DirectoryOutputSettings node to this parameter.""" - node_name = self._node.name - - has_incoming = False - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) - - if has_incoming: - return NodeMessageResult( - success=False, - details=f"{node_name}: {self._name} parameter already has an incoming connection", - response=button_details, - altered_workflow_state=False, - ) - - create_result = RetainedMode.create_node_relative_to( - reference_node_name=node_name, - new_node_type="DirectoryOutputSettings", - offset_side="left", - offset_x=-750, - offset_y=0, - lock=False, - ) - - if not isinstance(create_result, str): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to create DirectoryOutputSettings node", - response=button_details, - altered_workflow_state=False, - ) - - configure_node_name = create_result - - configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) - if configure_node is not None: - configure_node.set_parameter_value("situation", self._situation_name) - configure_node.publish_update_to_parameter("situation", self._situation_name) - - current_dirname = self._node.get_parameter_value(self._name) - if isinstance(current_dirname, str) and current_dirname: - configure_node.set_parameter_value("dirname", current_dirname) - configure_node.publish_update_to_parameter("dirname", current_dirname) - - connection_result = RetainedMode.connect( - source=f"{configure_node_name}.directory_destination", - destination=f"{node_name}.{self._name}", - ) - - if not connection_result.succeeded(): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to connect {configure_node_name}.directory_destination to {self._name}", - response=button_details, - altered_workflow_state=True, - ) - - return NodeMessageResult( - success=True, - details=f"{node_name}: Created and connected {configure_node_name}", - response=button_details, - altered_workflow_state=True, - ) - def _build_directory_destination_from_situation( dirname: str, diff --git a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py index ac338dd715..f4f3da8aab 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py @@ -1,24 +1,14 @@ """ProjectFileParameter - parameter component for project-aware file saving.""" -import logging - -from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter from griptape_nodes.files.file import FileDestination, FileDestinationProvider from griptape_nodes.files.project_file import ProjectFileDestination -from griptape_nodes.retained_mode.events.connection_events import ( - ListConnectionsForNodeRequest, - ListConnectionsForNodeResultSuccess, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.retained_mode.retained_mode import RetainedMode -from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload from griptape_nodes.traits.file_system_picker import FileSystemPicker -logger = logging.getLogger("griptape_nodes") - -class ProjectFileParameter: +class ProjectFileParameter(ProjectOutputParameter): """Parameter component for project-aware file saving. Adds a file path parameter to a node that, when processed, returns a @@ -50,28 +40,33 @@ def __init__( # noqa: PLR0913 allowed_modes: set[ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: - """Initialize with situation context. + super().__init__( + node, + name, + default_value=default_filename, + situation=situation, + allowed_modes=allowed_modes, + ui_options=ui_options, + ) - Args: - node: Parent node instance - name: Parameter name - default_filename: Default filename if parameter is empty - situation: Situation name (default: "save_node_output") - allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) - ui_options: Optional UI options to pass to the generated parameter - """ - self._node = node - self._name = name - self._situation_name = situation - self._default_filename = default_filename - self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} - self._ui_options = ui_options - - def add_parameter(self) -> None: - """Create and add the file path parameter to the node.""" - tooltip = f"Output filename (uses '{self._situation_name}' situation template)" - - traits: set = { + @property + def _settings_node_type(self) -> str: + return "FileOutputSettings" + + @property + def _settings_value_param_name(self) -> str: + return "filename" + + @property + def _settings_source_param_name(self) -> str: + return "file_destination" + + @property + def _parameter_output_type(self) -> str: + return "str" + + def _make_parameter_traits(self) -> set: + return { FileSystemPicker( allow_files=True, allow_directories=False, @@ -79,37 +74,11 @@ def add_parameter(self) -> None: ) } - if ParameterMode.INPUT in self._allowed_modes: - traits.add( - Button( - icon="cog", - size="icon", - variant="secondary", - tooltip="Create and connect a FileOutputSettings node", - on_click=self._on_configure_button_clicked, - ) - ) - - parameter = Parameter( - name=self._name, - type="str", - default_value=self._default_filename, - allowed_modes=self._allowed_modes, - tooltip=tooltip, - input_types=["str"], - output_type="str", - traits=traits, - ui_options=self._ui_options, - ) - parameter.on_incoming_connection_removed.append(self._reset_to_default) - - self._node.add_parameter(parameter) - def build_file(self, **extra_vars: str | int) -> FileDestination: """Build a FileDestination with a MacroPath from the parameter's current value. If an upstream node implements FileDestinationProvider (e.g., FileOutputSettings), - its FileDestination is retrieved directly without deserializing from the wire. + its FileDestination is retrieved directly without deserialising from the wire. An upstream provider that returns None (misconfigured) raises instead of silently falling back to the default situation, since that fallback hides user intent. @@ -126,28 +95,12 @@ def build_file(self, **extra_vars: str | int) -> FileDestination: Raises: ValueError: If an upstream FileDestinationProvider is connected but returns None. """ - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - for conn in result.incoming_connections: - if conn.target_parameter_name == self._name: - source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) - if isinstance(source_node, FileDestinationProvider): - file_dest = source_node.file_destination - if file_dest is None: - msg = ( - f"Attempted to build file destination for {self._node.name}.{self._name}. " - f"Failed because upstream node '{conn.source_node_name}' provides a " - f"FileDestination but returned None (likely missing a filename)." - ) - raise ValueError(msg) - return file_dest + upstream = self._get_upstream_destination(FileDestinationProvider, "file_destination", "FileDestination") + if upstream is not None: + return upstream # type: ignore[return-value] value = self._node.get_parameter_value(self._name) - - if isinstance(value, str) and value: - filename = value - else: - filename = self._default_filename + filename = value if isinstance(value, str) and value else self._default_value if "node_name" not in extra_vars: extra_vars["node_name"] = self._node.name @@ -157,79 +110,3 @@ def build_file(self, **extra_vars: str | int) -> FileDestination: self._situation_name, **extra_vars, ) - - def _reset_to_default(self, parameter: Parameter, source_node_name: str, source_parameter_name: str) -> None: # noqa: ARG002 - self._node.set_parameter_value(self._name, self._default_filename) - self._node.publish_update_to_parameter(self._name, self._default_filename) - - def _on_configure_button_clicked( - self, - button: Button, # noqa: ARG002 - button_details: ButtonDetailsMessagePayload, - ) -> NodeMessageResult: - """Create and connect a FileOutputSettings node to this parameter.""" - node_name = self._node.name - - has_incoming = False - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) - - if has_incoming: - return NodeMessageResult( - success=False, - details=f"{node_name}: {self._name} parameter already has an incoming connection", - response=button_details, - altered_workflow_state=False, - ) - - # TODO: https://github.com/griptape-ai/griptape-nodes/issues/4097 - # Replace with a non-RM utility for creating sibling nodes relative to a given node. - create_result = RetainedMode.create_node_relative_to( - reference_node_name=node_name, - new_node_type="FileOutputSettings", - offset_side="left", - offset_x=-750, - offset_y=0, - lock=False, - ) - - if not isinstance(create_result, str): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to create FileOutputSettings node", - response=button_details, - altered_workflow_state=False, - ) - - configure_node_name = create_result - - configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) - if configure_node is not None: - configure_node.set_parameter_value("situation", self._situation_name) - configure_node.publish_update_to_parameter("situation", self._situation_name) - - current_filename = self._node.get_parameter_value(self._name) - if isinstance(current_filename, str) and current_filename: - configure_node.set_parameter_value("filename", current_filename) - configure_node.publish_update_to_parameter("filename", current_filename) - - connection_result = RetainedMode.connect( - source=f"{configure_node_name}.file_destination", - destination=f"{node_name}.{self._name}", - ) - - if not connection_result.succeeded(): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to connect {configure_node_name}.file_destination to {self._name}", - response=button_details, - altered_workflow_state=True, - ) - - return NodeMessageResult( - success=True, - details=f"{node_name}: Created and connected {configure_node_name}", - response=button_details, - altered_workflow_state=True, - ) diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py new file mode 100644 index 0000000000..80bb631d6f --- /dev/null +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -0,0 +1,181 @@ +"""ProjectFileSequenceParameter - parameter component for project-aware file sequence output.""" + +import logging + +from griptape_nodes.common.macro_parser import ParsedMacro +from griptape_nodes.exe_types.core_types import ParameterMode +from griptape_nodes.exe_types.node_types import BaseNode +from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter +from griptape_nodes.files.file_sequence import ( + FileSequenceDestination, + build_versioned_sequence_destination, + hash_pattern_to_entry_macro, +) +from griptape_nodes.files.path_utils import FilenameParts +from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import ( + GetSituationRequest, + GetSituationResultSuccess, + MacroPath, +) +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes + +logger = logging.getLogger("griptape_nodes") + +_FALLBACK_SEQUENCE_MACRO = ( + "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{entry:04}.{file_extension}" +) + + +class FileSequenceDestinationProvider: + """Protocol for nodes that provide a FileSequenceDestination without serializing it over the wire.""" + + @property + def file_sequence_destination(self) -> FileSequenceDestination | None: ... + + +class ProjectFileSequenceParameter(ProjectOutputParameter): + """Parameter component for project-aware file sequence output. + + Adds a filename-pattern parameter to a node that, when processed, returns a + ``FileSequenceDestination`` with a versioned macro path for deferred resolution. + + The parameter accepts a filename like ``"render.exr"`` or a ``####`` pattern + like ``"render_####.exr"``. The situation macro wraps it with versioning. + + Usage: + # In node __init__: + self._seq_param = ProjectFileSequenceParameter( + node=self, + name="output_sequence", + default_filename="render.exr", + ) + self._seq_param.add_parameter() + + # In node process(): + dest = self._seq_param.build_sequence() + for i, entry_data in enumerate(entries): + dest.entry(i + 1).write_bytes(entry_data) + seq = dest.file_sequence + if seq is not None: + self.set_parameter_value("output_sequence", seq.location) + """ + + DEFAULT_SITUATION = "save_file_sequence_entry" + + def __init__( # noqa: PLR0913 + self, + node: BaseNode, + name: str, + *, + default_filename: str, + situation: str = DEFAULT_SITUATION, + allowed_modes: set[ParameterMode] | None = None, + ui_options: dict | None = None, + ) -> None: + super().__init__( + node, + name, + default_value=default_filename, + situation=situation, + allowed_modes=allowed_modes, + ui_options=ui_options, + ) + + @property + def _settings_node_type(self) -> str: + return "FileSequenceSettings" + + @property + def _settings_value_param_name(self) -> str: + return "filename" + + @property + def _settings_source_param_name(self) -> str: + return "sequence_destination" + + @property + def _parameter_output_type(self) -> str: + return "FileSequence" + + def build_sequence(self, **extra_vars: str | int) -> FileSequenceDestination: + """Build a FileSequenceDestination from the parameter's current value. + + If an upstream node implements FileSequenceDestinationProvider, its + FileSequenceDestination is retrieved directly. Otherwise the parameter's + string value (filename or #### pattern) is parsed and combined with the + situation macro. + + Args: + **extra_vars: Additional variables for the macro (e.g., sub_dirs="renders") + + Returns: + FileSequenceDestination with a versioned MacroPath and baked-in policy. + + Raises: + ValueError: If an upstream FileSequenceDestinationProvider returns None. + FileSequenceError: If no available version index can be found. + """ + upstream = self._get_upstream_destination( + FileSequenceDestinationProvider, "file_sequence_destination", "FileSequenceDestination" + ) + if upstream is not None: + return upstream # type: ignore[return-value] + + value = self._node.get_parameter_value(self._name) + filename = value if isinstance(value, str) and value else self._default_value + + if "node_name" not in extra_vars: + extra_vars["node_name"] = self._node.name + + return _build_sequence_destination_from_situation(filename, self._situation_name, **extra_vars) + + +def _build_sequence_destination_from_situation( + filename: str, + situation: str, + **extra_vars: str | int, +) -> FileSequenceDestination: + """Build a FileSequenceDestination from a project situation template. + + Parses the filename (or #### pattern) into parts, looks up the situation, + and builds a versioned destination by finding the first available ``_index``. + + Args: + filename: Filename or #### pattern (e.g., ``"render.exr"`` or ``"render_####.exr"``). + situation: Situation name to look up in the current project. + **extra_vars: Additional macro variables. + + Returns: + FileSequenceDestination with a locked version index. + """ + situation_result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation)) + + if isinstance(situation_result, GetSituationResultSuccess): + situation_obj = situation_result.situation + macro_template = situation_obj.macro + on_collision = situation_obj.policy.on_collision + existing_file_policy = SITUATION_TO_FILE_POLICY.get(on_collision, ExistingFilePolicy.OVERWRITE) + create_parents = situation_obj.policy.create_dirs + else: + logger.error("Failed to load situation '%s', using fallback sequence macro template", situation) + macro_template = _FALLBACK_SEQUENCE_MACRO + existing_file_policy = ExistingFilePolicy.OVERWRITE + create_parents = True + + normalized_filename = hash_pattern_to_entry_macro(filename) + parts = FilenameParts.from_filename(normalized_filename) + + variables: dict[str, str | int] = { + "file_name_base": parts.stem, + "file_extension": parts.extension, + **extra_vars, + } + + macro_path = MacroPath(ParsedMacro(macro_template), variables) + return build_versioned_sequence_destination( + macro_path, + existing_file_policy=existing_file_policy, + create_parents=create_parents, + ) diff --git a/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py deleted file mode 100644 index 8cf5190995..0000000000 --- a/src/griptape_nodes/exe_types/param_components/project_image_sequence_parameter.py +++ /dev/null @@ -1,299 +0,0 @@ -"""ProjectImageSequenceParameter - parameter component for project-aware image sequence output.""" - -import logging - -from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode -from griptape_nodes.exe_types.node_types import BaseNode -from griptape_nodes.files.image_sequence import ( - ImageSequenceDestination, - build_versioned_sequence_destination, - hash_pattern_to_frame_macro, -) -from griptape_nodes.files.path_utils import FilenameParts -from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY -from griptape_nodes.retained_mode.events.connection_events import ( - ListConnectionsForNodeRequest, - ListConnectionsForNodeResultSuccess, -) -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy -from griptape_nodes.retained_mode.events.project_events import ( - GetSituationRequest, - GetSituationResultSuccess, - MacroPath, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.retained_mode.retained_mode import RetainedMode -from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload - -logger = logging.getLogger("griptape_nodes") - -_FALLBACK_SEQUENCE_MACRO = ( - "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{frame:04}.{file_extension}" -) - - -class ImageSequenceDestinationProvider: - """Protocol for nodes that provide an ImageSequenceDestination without serializing it over the wire.""" - - @property - def image_sequence_destination(self) -> ImageSequenceDestination | None: ... - - -class ProjectImageSequenceParameter: - """Parameter component for project-aware image sequence output. - - Adds a filename-pattern parameter to a node that, when processed, returns an - ``ImageSequenceDestination`` with a versioned macro path for deferred resolution. - - The parameter accepts a filename like ``"frame.exr"`` or a ``####`` pattern - like ``"frame_####.exr"``. The situation macro wraps it with versioning. - - Usage: - # In node __init__: - self._seq_param = ProjectImageSequenceParameter( - node=self, - name="output_sequence", - default_filename="frame.exr", - ) - self._seq_param.add_parameter() - - # In node process(): - dest = self._seq_param.build_sequence() - for i, frame_data in enumerate(frames): - dest.frame(i + 1).write_bytes(frame_data) - seq = dest.image_sequence - if seq is not None: - self.set_parameter_value("output_sequence", seq.location) - """ - - DEFAULT_SITUATION = "save_image_sequence_frame" - - def __init__( # noqa: PLR0913 - self, - node: BaseNode, - name: str, - *, - default_filename: str, - situation: str = DEFAULT_SITUATION, - allowed_modes: set[ParameterMode] | None = None, - ui_options: dict | None = None, - ) -> None: - """Initialize with situation context. - - Args: - node: Parent node instance - name: Parameter name - default_filename: Default filename (e.g., ``"frame.exr"`` or ``"frame_####.exr"``) - situation: Situation name (default: "save_image_sequence_frame") - allowed_modes: Set of allowed parameter modes (default: INPUT, PROPERTY) - ui_options: Optional UI options to pass to the generated parameter - """ - self._node = node - self._name = name - self._situation_name = situation - self._default_filename = default_filename - self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} - self._ui_options = ui_options - - def add_parameter(self) -> None: - """Create and add the image sequence pattern parameter to the node.""" - tooltip = f"Output frame filename (uses '{self._situation_name}' situation template)" - - traits: set = set() - - if ParameterMode.INPUT in self._allowed_modes: - traits.add( - Button( - icon="cog", - size="icon", - variant="secondary", - tooltip="Create and connect an ImageSequenceSettings node", - on_click=self._on_configure_button_clicked, - ) - ) - - parameter = Parameter( - name=self._name, - type="str", - default_value=self._default_filename, - allowed_modes=self._allowed_modes, - tooltip=tooltip, - input_types=["str"], - output_type="ImageSequence", - traits=traits, - ui_options=self._ui_options, - ) - parameter.on_incoming_connection_removed.append(self._reset_to_default) - - self._node.add_parameter(parameter) - - def build_sequence(self, **extra_vars: str | int) -> ImageSequenceDestination: - """Build an ImageSequenceDestination from the parameter's current value. - - If an upstream node implements ImageSequenceDestinationProvider, its - ImageSequenceDestination is retrieved directly. Otherwise the parameter's - string value (filename or #### pattern) is parsed and combined with the - situation macro. - - Args: - **extra_vars: Additional variables for the macro (e.g., sub_dirs="renders") - - Returns: - ImageSequenceDestination with a versioned MacroPath and baked-in policy. - - Raises: - ValueError: If an upstream ImageSequenceDestinationProvider returns None. - ImageSequenceError: If no available version index can be found. - """ - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - for conn in result.incoming_connections: - if conn.target_parameter_name == self._name: - source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) - if isinstance(source_node, ImageSequenceDestinationProvider): - seq_dest = source_node.image_sequence_destination - if seq_dest is None: - msg = ( - f"Attempted to build image sequence destination for {self._node.name}.{self._name}. " - f"Failed because upstream node '{conn.source_node_name}' provides an " - f"ImageSequenceDestination but returned None (likely missing a filename)." - ) - raise ValueError(msg) - return seq_dest - - value = self._node.get_parameter_value(self._name) - filename = value if isinstance(value, str) and value else self._default_filename - - if "node_name" not in extra_vars: - extra_vars["node_name"] = self._node.name - - return _build_sequence_destination_from_situation(filename, self._situation_name, **extra_vars) - - def _reset_to_default( - self, - parameter: Parameter, # noqa: ARG002 - source_node_name: str, # noqa: ARG002 - source_parameter_name: str, # noqa: ARG002 - ) -> None: - self._node.set_parameter_value(self._name, self._default_filename) - self._node.publish_update_to_parameter(self._name, self._default_filename) - - def _on_configure_button_clicked( - self, - button: Button, # noqa: ARG002 - button_details: ButtonDetailsMessagePayload, - ) -> NodeMessageResult: - """Create and connect an ImageSequenceSettings node to this parameter.""" - node_name = self._node.name - - has_incoming = False - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): - has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) - - if has_incoming: - return NodeMessageResult( - success=False, - details=f"{node_name}: {self._name} parameter already has an incoming connection", - response=button_details, - altered_workflow_state=False, - ) - - create_result = RetainedMode.create_node_relative_to( - reference_node_name=node_name, - new_node_type="ImageSequenceSettings", - offset_side="left", - offset_x=-750, - offset_y=0, - lock=False, - ) - - if not isinstance(create_result, str): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to create ImageSequenceSettings node", - response=button_details, - altered_workflow_state=False, - ) - - configure_node_name = create_result - - configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) - if configure_node is not None: - configure_node.set_parameter_value("situation", self._situation_name) - configure_node.publish_update_to_parameter("situation", self._situation_name) - - current_filename = self._node.get_parameter_value(self._name) - if isinstance(current_filename, str) and current_filename: - configure_node.set_parameter_value("filename", current_filename) - configure_node.publish_update_to_parameter("filename", current_filename) - - connection_result = RetainedMode.connect( - source=f"{configure_node_name}.sequence_destination", - destination=f"{node_name}.{self._name}", - ) - - if not connection_result.succeeded(): - return NodeMessageResult( - success=False, - details=f"{node_name}: Failed to connect {configure_node_name}.sequence_destination to {self._name}", - response=button_details, - altered_workflow_state=True, - ) - - return NodeMessageResult( - success=True, - details=f"{node_name}: Created and connected {configure_node_name}", - response=button_details, - altered_workflow_state=True, - ) - - -def _build_sequence_destination_from_situation( - filename: str, - situation: str, - **extra_vars: str | int, -) -> ImageSequenceDestination: - """Build an ImageSequenceDestination from a project situation template. - - Parses the filename (or #### pattern) into parts, looks up the situation, - and builds a versioned destination by finding the first available ``_index``. - - Args: - filename: Filename or #### pattern (e.g., ``"frame.exr"`` or ``"frame_####.exr"``). - situation: Situation name to look up in the current project. - **extra_vars: Additional macro variables. - - Returns: - ImageSequenceDestination with a locked version index. - """ - situation_result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation)) - - if isinstance(situation_result, GetSituationResultSuccess): - situation_obj = situation_result.situation - macro_template = situation_obj.macro - on_collision = situation_obj.policy.on_collision - existing_file_policy = SITUATION_TO_FILE_POLICY.get(on_collision, ExistingFilePolicy.OVERWRITE) - create_parents = situation_obj.policy.create_dirs - else: - logger.error("Failed to load situation '%s', using fallback sequence macro template", situation) - macro_template = _FALLBACK_SEQUENCE_MACRO - existing_file_policy = ExistingFilePolicy.OVERWRITE - create_parents = True - - normalized_filename = hash_pattern_to_frame_macro(filename) - parts = FilenameParts.from_filename(normalized_filename) - - variables: dict[str, str | int] = { - "file_name_base": parts.stem, - "file_extension": parts.extension, - **extra_vars, - } - - macro_path = MacroPath(ParsedMacro(macro_template), variables) - return build_versioned_sequence_destination( - macro_path, - existing_file_policy=existing_file_policy, - create_parents=create_parents, - ) diff --git a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py new file mode 100644 index 0000000000..d61d6ca12d --- /dev/null +++ b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py @@ -0,0 +1,236 @@ +"""ProjectOutputParameter - shared base class for project-aware output parameter components.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode +from griptape_nodes.retained_mode.events.connection_events import ( + ListConnectionsForNodeRequest, + ListConnectionsForNodeResultSuccess, +) +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes +from griptape_nodes.retained_mode.retained_mode import RetainedMode +from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload + +if TYPE_CHECKING: + from griptape_nodes.exe_types.node_types import BaseNode + +logger = logging.getLogger("griptape_nodes") + + +class ProjectOutputParameter(ABC): + """Shared base for project-aware output parameter components. + + Handles the cog-button pattern (create + connect a settings node) and + upstream provider resolution, which are identical across all output types. + Subclasses supply the output-type-specific behaviour via abstract properties + and implement the ``build_xxx()`` method that returns the concrete destination. + """ + + def __init__( # noqa: PLR0913 + self, + node: BaseNode, + name: str, + *, + default_value: str, + situation: str, + allowed_modes: set[ParameterMode] | None = None, + ui_options: dict | None = None, + ) -> None: + """Initialise with situation context. + + Args: + node: Parent node instance. + name: Parameter name. + default_value: Default value when the parameter is empty. + situation: Situation name used to build the output path macro. + allowed_modes: Allowed parameter modes (default: INPUT, PROPERTY). + ui_options: Optional UI options forwarded to the generated parameter. + """ + self._node = node + self._name = name + self._situation_name = situation + self._default_value = default_value + self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} + self._ui_options = ui_options + + # ---- Abstract pieces each subclass must supply ---- + + @property + @abstractmethod + def _settings_node_type(self) -> str: + """Node type to create when the cog button is clicked (e.g. 'FileOutputSettings').""" + + @property + @abstractmethod + def _settings_value_param_name(self) -> str: + """Parameter name on the settings node that holds the filename/dirname (e.g. 'filename').""" + + @property + @abstractmethod + def _settings_source_param_name(self) -> str: + """Output parameter on the settings node to wire to this parameter (e.g. 'file_destination').""" + + @property + @abstractmethod + def _parameter_output_type(self) -> str: + """The output_type string for the generated parameter (e.g. 'str', 'Directory').""" + + # ---- Overridable hooks ---- + + def _make_parameter_traits(self) -> set: + """Return additional traits for the parameter (e.g. a FileSystemPicker). + + Returns an empty set by default; subclasses override to add pickers. + """ + return set() + + # ---- Shared concrete logic ---- + + def add_parameter(self) -> None: + """Create and add the output parameter to the node.""" + tooltip = f"Output path (uses '{self._situation_name}' situation template)" + + traits = self._make_parameter_traits() + if ParameterMode.INPUT in self._allowed_modes: + traits.add( + Button( + icon="cog", + size="icon", + variant="secondary", + tooltip=f"Create and connect a {self._settings_node_type} node", + on_click=self._on_configure_button_clicked, + ) + ) + + parameter = Parameter( + name=self._name, + type="str", + default_value=self._default_value, + allowed_modes=self._allowed_modes, + tooltip=tooltip, + input_types=["str"], + output_type=self._parameter_output_type, + traits=traits, + ui_options=self._ui_options, + ) + parameter.on_incoming_connection_removed.append(self._reset_to_default) + + self._node.add_parameter(parameter) + + def _reset_to_default( + self, + parameter: Parameter, # noqa: ARG002 + source_node_name: str, # noqa: ARG002 + source_parameter_name: str, # noqa: ARG002 + ) -> None: + self._node.set_parameter_value(self._name, self._default_value) + self._node.publish_update_to_parameter(self._name, self._default_value) + + def _get_upstream_destination( + self, + provider_type: type, + destination_attr: str, + destination_type_name: str, + ) -> object | None: + """Return the upstream provider's destination, or None if no provider is connected. + + Raises: + ValueError: If a provider is connected but returns None. + """ + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) + if not isinstance(result, ListConnectionsForNodeResultSuccess): + return None + + for conn in result.incoming_connections: + if conn.target_parameter_name != self._name: + continue + source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) + if not isinstance(source_node, provider_type): + continue + destination = getattr(source_node, destination_attr) + if destination is None: + msg = ( + f"Attempted to build {destination_type_name} for {self._node.name}.{self._name}. " + f"Failed because upstream node '{conn.source_node_name}' returned None " + f"(likely missing a filename or path)." + ) + raise ValueError(msg) + return destination + + return None + + def _on_configure_button_clicked( + self, + button: Button, # noqa: ARG002 + button_details: ButtonDetailsMessagePayload, + ) -> NodeMessageResult: + """Create and connect the appropriate settings node to this parameter.""" + node_name = self._node.name + + has_incoming = False + result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) + if isinstance(result, ListConnectionsForNodeResultSuccess): + has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) + + if has_incoming: + return NodeMessageResult( + success=False, + details=f"{node_name}: {self._name} parameter already has an incoming connection", + response=button_details, + altered_workflow_state=False, + ) + + # TODO: https://github.com/griptape-ai/griptape-nodes/issues/4097 + # Replace with a non-RM utility for creating sibling nodes relative to a given node. + create_result = RetainedMode.create_node_relative_to( + reference_node_name=node_name, + new_node_type=self._settings_node_type, + offset_side="left", + offset_x=-750, + offset_y=0, + lock=False, + ) + + if not isinstance(create_result, str): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to create {self._settings_node_type} node", + response=button_details, + altered_workflow_state=False, + ) + + configure_node_name = create_result + + configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) + if configure_node is not None: + configure_node.set_parameter_value("situation", self._situation_name) + configure_node.publish_update_to_parameter("situation", self._situation_name) + + current_value = self._node.get_parameter_value(self._name) + if isinstance(current_value, str) and current_value: + configure_node.set_parameter_value(self._settings_value_param_name, current_value) + configure_node.publish_update_to_parameter(self._settings_value_param_name, current_value) + + connection_result = RetainedMode.connect( + source=f"{configure_node_name}.{self._settings_source_param_name}", + destination=f"{node_name}.{self._name}", + ) + + if not connection_result.succeeded(): + return NodeMessageResult( + success=False, + details=f"{node_name}: Failed to connect {configure_node_name}.{self._settings_source_param_name} to {self._name}", + response=button_details, + altered_workflow_state=True, + ) + + return NodeMessageResult( + success=True, + details=f"{node_name}: Created and connected {configure_node_name}", + response=button_details, + altered_workflow_state=True, + ) diff --git a/src/griptape_nodes/files/__init__.py b/src/griptape_nodes/files/__init__.py index 00b94059c3..823ec36215 100644 --- a/src/griptape_nodes/files/__init__.py +++ b/src/griptape_nodes/files/__init__.py @@ -6,8 +6,8 @@ To use the Directory API: from griptape_nodes.files.directory import Directory, DirectoryDestination, DirectoryError -To use the ImageSequence API: - from griptape_nodes.files.image_sequence import ImageSequence, ImageSequenceDestination, ImageSequenceError +To use the FileSequence API: + from griptape_nodes.files.file_sequence import FileSequence, FileSequenceDestination, FileSequenceError """ from griptape_nodes.files.base_file_driver import BaseFileDriver diff --git a/src/griptape_nodes/files/image_sequence.py b/src/griptape_nodes/files/file_sequence.py similarity index 54% rename from src/griptape_nodes/files/image_sequence.py rename to src/griptape_nodes/files/file_sequence.py index bccb6578ba..ee93b37f28 100644 --- a/src/griptape_nodes/files/image_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -1,4 +1,4 @@ -"""ImageSequence - a collection of images identified by a frame-number pattern.""" +"""FileSequence - a collection of files identified by an entry-number pattern.""" from __future__ import annotations @@ -20,59 +20,59 @@ if TYPE_CHECKING: from collections.abc import Callable -_FRAME_VAR_NAME = "frame" +_ENTRY_VAR_NAME = "entry" _HASH_PATTERN = re.compile(r"#+") -_FRAME_MACRO_PATTERN = re.compile(r"\{frame(?::(\d+))?\}") +_ENTRY_MACRO_PATTERN = re.compile(r"\{entry(?::(\d+))?\}") _MAX_VERSION_INDEX = 9999 -class ImageSequenceError(Exception): - """Raised when an image sequence operation fails.""" +class FileSequenceError(Exception): + """Raised when a file sequence operation fails.""" def __init__(self, result_details: str) -> None: self.result_details = result_details super().__init__(result_details) -class ImageSequence: - """A collection of images identified by a frame-number pattern. +class FileSequence: + """A collection of files identified by an entry-number pattern. - Internally stores a MacroPath template with a ``{frame:04}`` variable slot. + Internally stores a MacroPath template with a ``{entry:04}`` variable slot. Exposes the industry-standard ``####`` notation at property boundaries for DCC software interop. - Use ``frame(n)`` to get a ``File`` for reading a specific frame, and + Use ``entry(n)`` to get a ``File`` for reading a specific entry, and ``directory`` to get the containing folder. """ - def __init__(self, frame_macro: MacroPath) -> None: - """Store the frame macro. No I/O is performed. + def __init__(self, entry_macro: MacroPath) -> None: + """Store the entry macro. No I/O is performed. Args: - frame_macro: MacroPath whose template contains a ``{frame}`` variable + entry_macro: MacroPath whose template contains an ``{entry}`` variable slot and whose variables dict holds all resolved values (including a locked ``_index`` when versioning is in effect). """ - self._frame_macro = frame_macro + self._entry_macro = entry_macro @property def location(self) -> str: - """Return the raw macro template for wire serialization. + """Return the raw macro template for wire serialisation. - Example: ``"{outputs}/Render_frames_v001/{frame:04}.exr"`` + Example: ``"{outputs}/dialogue_v001/{entry:04}.wav"`` """ - return self._frame_macro.parsed_macro.template + return self._entry_macro.parsed_macro.template @property def pattern(self) -> str: - """Return the #### notation form of this sequence's frame pattern. + """Return the #### notation form of this sequence's entry pattern. - The ``{frame:NN}`` macro variable is replaced with a run of ``#`` + The ``{entry:NN}`` macro variable is replaced with a run of ``#`` characters matching the padding width. Example: ``"{outputs}/renders_v001/frame_####.exr"`` """ - return frame_macro_to_hash_pattern(self.location) + return entry_macro_to_hash_pattern(self.location) @property def directory(self) -> Directory: @@ -84,32 +84,32 @@ def directory(self) -> Directory: dir_location = str(Path(self.location).parent) return Directory(dir_location) - def frame(self, frame_number: int) -> File: - """Return a File for reading a specific frame. + def entry(self, entry_number: int) -> File: + """Return a File for reading a specific entry. Args: - frame_number: Frame index (caller's convention, e.g. 0-based or 1-based). + entry_number: Entry index (caller's convention, e.g. 0-based or 1-based). Returns: - File that resolves to the absolute path of that frame. + File that resolves to the absolute path of that entry. """ - variables = {**self._frame_macro.variables, _FRAME_VAR_NAME: frame_number} - return File(MacroPath(self._frame_macro.parsed_macro, variables)) + variables = {**self._entry_macro.variables, _ENTRY_VAR_NAME: entry_number} + return File(MacroPath(self._entry_macro.parsed_macro, variables)) -class _FrameWriteDestination(ProjectFileDestination): +class _EntryWriteDestination(ProjectFileDestination): """FileDestination subclass that fires a callback after each successful write.""" def __init__( self, - frame_path: MacroPath, + entry_path: MacroPath, *, existing_file_policy: ExistingFilePolicy, create_parents: bool, on_written: Callable[[File], None], ) -> None: super().__init__( - frame_path, + entry_path, existing_file_policy=existing_file_policy, create_parents=create_parents, ) @@ -136,147 +136,147 @@ async def awrite_text(self, content: str, encoding: str = "utf-8") -> File: return result -class ImageSequenceDestination: - """A pre-configured write handle for an image sequence. +class FileSequenceDestination: + """A pre-configured write handle for a file sequence. - Bundles a frame macro path and write policy. The caller resolves a + Bundles an entry macro path and write policy. The caller resolves a version index once (via ``build_versioned_sequence_destination``), then - calls ``frame(n)`` to get a ``FileDestination`` for each frame. + calls ``entry(n)`` to get a ``FileDestination`` for each entry. - The ``image_sequence`` property becomes non-None after the first frame write. + The ``file_sequence`` property becomes non-None after the first entry write. """ def __init__( self, - frame_macro: MacroPath, + entry_macro: MacroPath, *, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE, create_parents: bool = True, ) -> None: - """Store frame macro and write configuration. No I/O is performed. + """Store entry macro and write configuration. No I/O is performed. Args: - frame_macro: MacroPath with template containing a ``{frame}`` variable. + entry_macro: MacroPath with template containing an ``{entry}`` variable. Should already have ``_index`` locked in the variables dict. - existing_file_policy: How to handle existing frame files. Defaults to OVERWRITE. + existing_file_policy: How to handle existing entry files. Defaults to OVERWRITE. create_parents: If True, create parent directories automatically. Defaults to True. """ - self._frame_macro = frame_macro + self._entry_macro = entry_macro self._existing_file_policy = existing_file_policy self._create_parents = create_parents - self._written_sequence: ImageSequence | None = None + self._written_sequence: FileSequence | None = None @property - def image_sequence(self) -> ImageSequence | None: - """Return the ImageSequence descriptor after at least one frame has been written. + def file_sequence(self) -> FileSequence | None: + """Return the FileSequence descriptor after at least one entry has been written. - Returns None before any frame write. + Returns None before any entry write. """ return self._written_sequence - def frame(self, frame_number: int) -> FileDestination: - """Return a FileDestination for writing a specific frame. + def entry(self, entry_number: int) -> FileDestination: + """Return a FileDestination for writing a specific entry. - After the returned destination is used to write, the ``image_sequence`` + After the returned destination is used to write, the ``file_sequence`` property becomes available. Args: - frame_number: Frame index to write. + entry_number: Entry index to write. Returns: - FileDestination pre-configured with the resolved frame path and policy. + FileDestination pre-configured with the resolved entry path and policy. """ - variables = {**self._frame_macro.variables, _FRAME_VAR_NAME: frame_number} - frame_path = MacroPath(self._frame_macro.parsed_macro, variables) - return _FrameWriteDestination( - frame_path, + variables = {**self._entry_macro.variables, _ENTRY_VAR_NAME: entry_number} + entry_path = MacroPath(self._entry_macro.parsed_macro, variables) + return _EntryWriteDestination( + entry_path, existing_file_policy=self._existing_file_policy, create_parents=self._create_parents, - on_written=self._on_frame_written, + on_written=self._on_entry_written, ) - def _on_frame_written(self, written_file: File) -> None: # noqa: ARG002 - """Record that a frame was written to expose the ImageSequence descriptor.""" + def _on_entry_written(self, written_file: File) -> None: # noqa: ARG002 + """Record that an entry was written to expose the FileSequence descriptor.""" if self._written_sequence is None: - self._written_sequence = ImageSequence(self._frame_macro) + self._written_sequence = FileSequence(self._entry_macro) def build_versioned_sequence_destination( - frame_macro: MacroPath, + entry_macro: MacroPath, *, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE, create_parents: bool = True, -) -> ImageSequenceDestination: - """Find the first available version index and return a locked ImageSequenceDestination. +) -> FileSequenceDestination: + """Find the first available version index and return a locked FileSequenceDestination. - Increments ``_index`` in the frame macro starting at 1 until the corresponding - parent directory does not exist. Returns an ``ImageSequenceDestination`` with + Increments ``_index`` in the entry macro starting at 1 until the corresponding + parent directory does not exist. Returns a ``FileSequenceDestination`` with that index locked into the variables dict. Args: - frame_macro: MacroPath template with ``{frame}`` and ``{_index}`` variables. - existing_file_policy: Policy for individual frame files. Defaults to OVERWRITE. + entry_macro: MacroPath template with ``{entry}`` and ``{_index}`` variables. + existing_file_policy: Policy for individual entry files. Defaults to OVERWRITE. create_parents: Whether to create parent directories. Defaults to True. Returns: - ImageSequenceDestination with a locked _index version. + FileSequenceDestination with a locked _index version. Raises: - ImageSequenceError: If no available version is found within the limit. + FileSequenceError: If no available version is found within the limit. """ for index in range(1, _MAX_VERSION_INDEX + 1): - probe_variables = {**frame_macro.variables, "_index": index, _FRAME_VAR_NAME: 0} + probe_variables = {**entry_macro.variables, "_index": index, _ENTRY_VAR_NAME: 0} resolve_result = GriptapeNodes.handle_request( - GetPathForMacroRequest(parsed_macro=frame_macro.parsed_macro, variables=probe_variables) + GetPathForMacroRequest(parsed_macro=entry_macro.parsed_macro, variables=probe_variables) ) if not isinstance(resolve_result, GetPathForMacroResultSuccess): msg = f"Attempted to find available sequence version. Failed to resolve macro: {resolve_result.result_details}" - raise ImageSequenceError(msg) + raise FileSequenceError(msg) parent_dir = resolve_result.absolute_path.parent if not parent_dir.exists(): - locked_vars = {**frame_macro.variables, "_index": index} - locked_macro = MacroPath(frame_macro.parsed_macro, locked_vars) - return ImageSequenceDestination( + locked_vars = {**entry_macro.variables, "_index": index} + locked_macro = MacroPath(entry_macro.parsed_macro, locked_vars) + return FileSequenceDestination( locked_macro, existing_file_policy=existing_file_policy, create_parents=create_parents, ) msg = f"Attempted to find available sequence version. Failed because no path found after {_MAX_VERSION_INDEX} attempts." - raise ImageSequenceError(msg) + raise FileSequenceError(msg) -def hash_pattern_to_frame_macro(pattern: str) -> str: - """Convert a #### frame pattern to a macro template with {frame:NN} syntax. +def hash_pattern_to_entry_macro(pattern: str) -> str: + """Convert a #### entry pattern to a macro template with {entry:NN} syntax. Args: pattern: Pattern string like ``"render_####.exr"`` or ``"frame_##.png"``. Returns: - Macro template string like ``"render_{frame:04}.exr"``. + Macro template string like ``"render_{entry:04}.exr"``. """ def replace_hashes(match: re.Match) -> str: width = len(match.group()) - return f"{{frame:{width:02d}}}" + return f"{{entry:{width:02d}}}" return _HASH_PATTERN.sub(replace_hashes, pattern) -def frame_macro_to_hash_pattern(template: str) -> str: - """Convert a macro template with {frame:NN} syntax to a #### pattern. +def entry_macro_to_hash_pattern(template: str) -> str: + """Convert a macro template with {entry:NN} syntax to a #### pattern. Args: - template: Macro template like ``"render_{frame:04}.exr"``. + template: Macro template like ``"render_{entry:04}.exr"``. Returns: Pattern string like ``"render_####.exr"``. """ - def replace_frame_var(match: re.Match) -> str: + def replace_entry_var(match: re.Match) -> str: width_str = match.group(1) width = int(width_str) if width_str else 4 return "#" * width - return _FRAME_MACRO_PATTERN.sub(replace_frame_var, template) + return _ENTRY_MACRO_PATTERN.sub(replace_entry_var, template) diff --git a/tests/unit/exe_types/param_components/test_project_image_sequence_parameter.py b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py similarity index 79% rename from tests/unit/exe_types/param_components/test_project_image_sequence_parameter.py rename to tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py index 96da23ce60..360d2f1ff5 100644 --- a/tests/unit/exe_types/param_components/test_project_image_sequence_parameter.py +++ b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py @@ -7,11 +7,11 @@ SituationPolicy, SituationTemplate, ) -from griptape_nodes.exe_types.param_components.project_image_sequence_parameter import ( +from griptape_nodes.exe_types.param_components.project_file_sequence_parameter import ( _FALLBACK_SEQUENCE_MACRO, _build_sequence_destination_from_situation, ) -from griptape_nodes.files.image_sequence import ImageSequenceDestination +from griptape_nodes.files.file_sequence import FileSequenceDestination from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy from griptape_nodes.retained_mode.events.project_events import ( GetSituationResultFailure, @@ -19,10 +19,10 @@ ) HANDLE_REQUEST_PATH = ( - "griptape_nodes.exe_types.param_components.project_image_sequence_parameter.GriptapeNodes.handle_request" + "griptape_nodes.exe_types.param_components.project_file_sequence_parameter.GriptapeNodes.handle_request" ) BUILD_VERSIONED_PATH = ( - "griptape_nodes.exe_types.param_components.project_image_sequence_parameter.build_versioned_sequence_destination" + "griptape_nodes.exe_types.param_components.project_file_sequence_parameter.build_versioned_sequence_destination" ) _POLICY_MAP = { @@ -50,17 +50,17 @@ class TestBuildSequenceDestinationFromSituation: def test_uses_situation_macro(self) -> None: situation_macro = ( - "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_{frame:04}.{file_extension}" + "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_{entry:04}.{file_extension}" ) situation = _make_situation(situation_macro) success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") call_args = mock_build.call_args macro_path = call_args.args[0] @@ -68,7 +68,7 @@ def test_uses_situation_macro(self) -> None: def test_falls_back_to_default_macro_when_situation_not_found(self) -> None: failure = GetSituationResultFailure(result_details="not found") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=failure), @@ -81,79 +81,79 @@ def test_falls_back_to_default_macro_when_situation_not_found(self) -> None: assert macro_path.parsed_macro.template == _FALLBACK_SEQUENCE_MACRO def test_plain_filename_parsed_into_stem_and_extension(self) -> None: - situation = _make_situation("{outputs}/{file_name_base}_{frame:04}.{file_extension}") + situation = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") macro_path = mock_build.call_args.args[0] assert macro_path.variables["file_name_base"] == "frame" assert macro_path.variables["file_extension"] == "exr" def test_hash_pattern_filename_converted_before_parsing(self) -> None: - situation = _make_situation("{outputs}/{file_name_base}_{frame:04}.{file_extension}") + situation = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame_####.exr", "save_image_sequence_frame") + _build_sequence_destination_from_situation("frame_####.exr", "save_file_sequence_entry") macro_path = mock_build.call_args.args[0] assert macro_path.variables["file_extension"] == "exr" def test_extra_vars_forwarded_to_macro(self) -> None: - situation = _make_situation("{outputs}/{node_name}/{file_name_base}_{frame:04}.{file_extension}") + situation = _make_situation("{outputs}/{node_name}/{file_name_base}_{entry:04}.{file_extension}") success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame", node_name="MyNode") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry", node_name="MyNode") macro_path = mock_build.call_args.args[0] assert macro_path.variables["node_name"] == "MyNode" def test_situation_overwrite_policy_forwarded(self) -> None: - situation = _make_situation("{outputs}/{frame:04}.exr", on_collision="OVERWRITE") + situation = _make_situation("{outputs}/{entry:04}.exr", on_collision="OVERWRITE") success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") call_kwargs = mock_build.call_args.kwargs assert call_kwargs["existing_file_policy"] == ExistingFilePolicy.OVERWRITE def test_situation_create_dirs_forwarded(self) -> None: - situation = _make_situation("{outputs}/{frame:04}.exr", create_dirs=False) + situation = _make_situation("{outputs}/{entry:04}.exr", create_dirs=False) success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") call_kwargs = mock_build.call_args.kwargs assert call_kwargs["create_parents"] is False def test_fallback_uses_overwrite_policy(self) -> None: failure = GetSituationResultFailure(result_details="not found") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=failure), @@ -164,20 +164,20 @@ def test_fallback_uses_overwrite_policy(self) -> None: call_kwargs = mock_build.call_args.kwargs assert call_kwargs["existing_file_policy"] == ExistingFilePolicy.OVERWRITE - def test_returns_image_sequence_destination(self) -> None: - situation = _make_situation("{outputs}/{frame:04}.exr") + def test_returns_file_sequence_destination(self) -> None: + situation = _make_situation("{outputs}/{entry:04}.exr") success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest): - result = _build_sequence_destination_from_situation("frame.exr", "save_image_sequence_frame") + result = _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") assert result is mock_dest def test_multiple_extra_vars_all_forwarded(self) -> None: - situation = _make_situation("{outputs}/{file_name_base}_{frame:04}.{file_extension}") + situation = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=ImageSequenceDestination) + mock_dest = MagicMock(spec=FileSequenceDestination) with ( patch(HANDLE_REQUEST_PATH, return_value=success), @@ -185,7 +185,7 @@ def test_multiple_extra_vars_all_forwarded(self) -> None: ): _build_sequence_destination_from_situation( "render.exr", - "save_image_sequence_frame", + "save_file_sequence_entry", node_name="Renderer", sub_dirs="pass_1", ) diff --git a/tests/unit/files/test_image_sequence.py b/tests/unit/files/test_file_sequence.py similarity index 51% rename from tests/unit/files/test_image_sequence.py rename to tests/unit/files/test_file_sequence.py index ee6e52f768..d1f991a599 100644 --- a/tests/unit/files/test_image_sequence.py +++ b/tests/unit/files/test_file_sequence.py @@ -1,4 +1,4 @@ -"""Unit tests for ImageSequence and ImageSequenceDestination.""" +"""Unit tests for FileSequence and FileSequenceDestination.""" from pathlib import Path from unittest.mock import patch @@ -6,13 +6,13 @@ import pytest from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.files.image_sequence import ( - ImageSequence, - ImageSequenceDestination, - ImageSequenceError, +from griptape_nodes.files.file_sequence import ( + FileSequence, + FileSequenceDestination, + FileSequenceError, build_versioned_sequence_destination, - frame_macro_to_hash_pattern, - hash_pattern_to_frame_macro, + entry_macro_to_hash_pattern, + hash_pattern_to_entry_macro, ) from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy from griptape_nodes.retained_mode.events.project_events import ( @@ -22,235 +22,234 @@ PathResolutionFailureReason, ) -HANDLE_REQUEST_PATH = "griptape_nodes.files.image_sequence.GriptapeNodes.handle_request" +HANDLE_REQUEST_PATH = "griptape_nodes.files.file_sequence.GriptapeNodes.handle_request" class TestHashPatternConversion: - """Tests for hash_pattern_to_frame_macro and frame_macro_to_hash_pattern (pure functions).""" + """Tests for hash_pattern_to_entry_macro and entry_macro_to_hash_pattern (pure functions).""" - def test_hash_to_frame_macro_four_hashes(self) -> None: - assert hash_pattern_to_frame_macro("####") == "{frame:04}" + def test_hash_to_entry_macro_four_hashes(self) -> None: + assert hash_pattern_to_entry_macro("####") == "{entry:04}" - def test_hash_to_frame_macro_two_hashes(self) -> None: - assert hash_pattern_to_frame_macro("##") == "{frame:02}" + def test_hash_to_entry_macro_two_hashes(self) -> None: + assert hash_pattern_to_entry_macro("##") == "{entry:02}" - def test_hash_to_frame_macro_six_hashes(self) -> None: - assert hash_pattern_to_frame_macro("######") == "{frame:06}" + def test_hash_to_entry_macro_six_hashes(self) -> None: + assert hash_pattern_to_entry_macro("######") == "{entry:06}" - def test_hash_to_frame_macro_with_prefix_and_suffix(self) -> None: - assert hash_pattern_to_frame_macro("render_####.exr") == "render_{frame:04}.exr" + def test_hash_to_entry_macro_with_prefix_and_suffix(self) -> None: + assert hash_pattern_to_entry_macro("render_####.exr") == "render_{entry:04}.exr" - def test_hash_to_frame_macro_no_hashes_unchanged(self) -> None: - assert hash_pattern_to_frame_macro("frame.exr") == "frame.exr" + def test_hash_to_entry_macro_no_hashes_unchanged(self) -> None: + assert hash_pattern_to_entry_macro("frame.exr") == "frame.exr" - def test_hash_to_frame_macro_single_hash(self) -> None: - assert hash_pattern_to_frame_macro("#") == "{frame:01}" + def test_hash_to_entry_macro_single_hash(self) -> None: + assert hash_pattern_to_entry_macro("#") == "{entry:01}" - def test_frame_macro_to_hash_with_explicit_width(self) -> None: - assert frame_macro_to_hash_pattern("{frame:06}") == "######" + def test_entry_macro_to_hash_with_explicit_width(self) -> None: + assert entry_macro_to_hash_pattern("{entry:06}") == "######" - def test_frame_macro_to_hash_four_width(self) -> None: - assert frame_macro_to_hash_pattern("{frame:04}") == "####" + def test_entry_macro_to_hash_four_width(self) -> None: + assert entry_macro_to_hash_pattern("{entry:04}") == "####" - def test_frame_macro_to_hash_no_width_defaults_to_4(self) -> None: - assert frame_macro_to_hash_pattern("{frame}") == "####" + def test_entry_macro_to_hash_no_width_defaults_to_4(self) -> None: + assert entry_macro_to_hash_pattern("{entry}") == "####" - def test_frame_macro_to_hash_with_prefix_and_suffix(self) -> None: - assert frame_macro_to_hash_pattern("frame_{frame:04}.exr") == "frame_####.exr" + def test_entry_macro_to_hash_with_prefix_and_suffix(self) -> None: + assert entry_macro_to_hash_pattern("frame_{entry:04}.exr") == "frame_####.exr" - def test_frame_macro_to_hash_no_frame_var_unchanged(self) -> None: - assert frame_macro_to_hash_pattern("frame.exr") == "frame.exr" + def test_entry_macro_to_hash_no_entry_var_unchanged(self) -> None: + assert entry_macro_to_hash_pattern("frame.exr") == "frame.exr" def test_roundtrip_hash_to_macro_to_hash(self) -> None: original = "render_####.exr" - assert frame_macro_to_hash_pattern(hash_pattern_to_frame_macro(original)) == original + assert entry_macro_to_hash_pattern(hash_pattern_to_entry_macro(original)) == original def test_roundtrip_macro_to_hash_to_macro(self) -> None: - original = "render_{frame:04}.exr" - assert hash_pattern_to_frame_macro(frame_macro_to_hash_pattern(original)) == original + original = "render_{entry:04}.exr" + assert hash_pattern_to_entry_macro(entry_macro_to_hash_pattern(original)) == original -class TestImageSequenceConstructor: - """Tests that ImageSequence constructor stores the frame macro without I/O.""" +class TestFileSequenceConstructor: + """Tests that FileSequence constructor stores the entry macro without I/O.""" - def test_stores_frame_macro(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - seq = ImageSequence(macro_path) - assert seq._frame_macro is macro_path + def test_stores_entry_macro(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + seq = FileSequence(macro_path) + assert seq._entry_macro is macro_path def test_does_no_io(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) with patch(HANDLE_REQUEST_PATH) as mock_handle: - ImageSequence(macro_path) + FileSequence(macro_path) mock_handle.assert_not_called() -class TestImageSequenceLocation: - """Tests for ImageSequence.location property.""" +class TestFileSequenceLocation: + """Tests for FileSequence.location property.""" def test_location_returns_macro_template(self) -> None: - template = "{outputs}/frames/frame_{frame:04}.exr" + template = "{outputs}/frames/frame_{entry:04}.exr" macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) - seq = ImageSequence(macro_path) + seq = FileSequence(macro_path) assert seq.location == template def test_location_no_io_performed(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) with patch(HANDLE_REQUEST_PATH) as mock_handle: - seq = ImageSequence(macro_path) + seq = FileSequence(macro_path) _ = seq.location mock_handle.assert_not_called() -class TestImageSequencePattern: - """Tests for ImageSequence.pattern property.""" +class TestFileSequencePattern: + """Tests for FileSequence.pattern property.""" - def test_pattern_converts_frame_var_to_hashes(self) -> None: - template = "{outputs}/frames/frame_{frame:04}.exr" + def test_pattern_converts_entry_var_to_hashes(self) -> None: + template = "{outputs}/frames/frame_{entry:04}.exr" macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) - seq = ImageSequence(macro_path) + seq = FileSequence(macro_path) assert seq.pattern == "{outputs}/frames/frame_####.exr" - def test_pattern_with_six_digit_frame(self) -> None: - template = "renders/frame_{frame:06}.png" + def test_pattern_with_six_digit_entry(self) -> None: + template = "renders/frame_{entry:06}.png" macro_path = MacroPath(ParsedMacro(template), {}) - seq = ImageSequence(macro_path) + seq = FileSequence(macro_path) assert seq.pattern == "renders/frame_######.png" -class TestImageSequenceDirectory: - """Tests for ImageSequence.directory property.""" +class TestFileSequenceDirectory: + """Tests for FileSequence.directory property.""" def test_directory_returns_parent_of_template(self) -> None: - template = "{outputs}/frames/frame_{frame:04}.exr" + template = "{outputs}/frames/frame_{entry:04}.exr" macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) - seq = ImageSequence(macro_path) + seq = FileSequence(macro_path) directory = seq.directory assert directory.location == "{outputs}/frames" def test_directory_no_io_performed(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) with patch(HANDLE_REQUEST_PATH) as mock_handle: - seq = ImageSequence(macro_path) + seq = FileSequence(macro_path) _ = seq.directory mock_handle.assert_not_called() -class TestImageSequenceFrame: - """Tests for ImageSequence.frame() method.""" +class TestFileSequenceEntry: + """Tests for FileSequence.entry() method.""" - def test_frame_returns_file_with_correct_frame_number(self) -> None: - frame_number = 5 - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - seq = ImageSequence(macro_path) - file = seq.frame(frame_number) + def test_entry_returns_file_with_correct_entry_number(self) -> None: + entry_number = 5 + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + seq = FileSequence(macro_path) + file = seq.entry(entry_number) assert isinstance(file._file_path, MacroPath) - assert file._file_path.variables["frame"] == frame_number + assert file._file_path.variables["entry"] == entry_number - def test_frame_inherits_locked_index_variable(self) -> None: + def test_entry_inherits_locked_index_variable(self) -> None: locked_index = 7 - frame_number = 3 + entry_number = 3 macro_path = MacroPath( - ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {"_index": locked_index} + ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {"_index": locked_index} ) - seq = ImageSequence(macro_path) - file = seq.frame(frame_number) + seq = FileSequence(macro_path) + file = seq.entry(entry_number) assert isinstance(file._file_path, MacroPath) assert file._file_path.variables["_index"] == locked_index - assert file._file_path.variables["frame"] == frame_number + assert file._file_path.variables["entry"] == entry_number - def test_frame_does_no_io(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - seq = ImageSequence(macro_path) + def test_entry_does_no_io(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + seq = FileSequence(macro_path) with patch(HANDLE_REQUEST_PATH) as mock_handle: - seq.frame(0) + seq.entry(0) mock_handle.assert_not_called() - def test_frame_zero(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) - seq = ImageSequence(macro_path) - file = seq.frame(0) + def test_entry_zero(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) + seq = FileSequence(macro_path) + file = seq.entry(0) assert isinstance(file._file_path, MacroPath) - assert file._file_path.variables["frame"] == 0 + assert file._file_path.variables["entry"] == 0 -class TestImageSequenceDestination: - """Tests for ImageSequenceDestination.""" +class TestFileSequenceDestination: + """Tests for FileSequenceDestination.""" - def test_image_sequence_is_none_before_write(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - dest = ImageSequenceDestination(macro_path) - assert dest.image_sequence is None + def test_file_sequence_is_none_before_write(self) -> None: + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + dest = FileSequenceDestination(macro_path) + assert dest.file_sequence is None - def test_frame_returns_file_destination(self) -> None: + def test_entry_returns_file_destination(self) -> None: from griptape_nodes.files.file import FileDestination - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - dest = ImageSequenceDestination(macro_path) - frame_dest = dest.frame(1) - assert isinstance(frame_dest, FileDestination) + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + dest = FileSequenceDestination(macro_path) + entry_dest = dest.entry(1) + assert isinstance(entry_dest, FileDestination) - def test_frame_destination_has_correct_frame_variable(self) -> None: - frame_number = 42 - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - dest = ImageSequenceDestination(macro_path) - frame_dest = dest.frame(frame_number) - assert isinstance(frame_dest._file._file_path, MacroPath) - assert frame_dest._file._file_path.variables["frame"] == frame_number + def test_entry_destination_has_correct_entry_variable(self) -> None: + entry_number = 42 + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + dest = FileSequenceDestination(macro_path) + entry_dest = dest.entry(entry_number) + assert isinstance(entry_dest._file._file_path, MacroPath) + assert entry_dest._file._file_path.variables["entry"] == entry_number - def test_on_frame_written_sets_image_sequence(self) -> None: + def test_on_entry_written_sets_file_sequence(self) -> None: from griptape_nodes.files.file import File - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - dest = ImageSequenceDestination(macro_path) - assert dest.image_sequence is None - dest._on_frame_written(File("workspace/frame_0001.exr")) - assert dest.image_sequence is not None - assert isinstance(dest.image_sequence, ImageSequence) + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + dest = FileSequenceDestination(macro_path) + assert dest.file_sequence is None + dest._on_entry_written(File("workspace/frame_0001.exr")) + assert dest.file_sequence is not None + assert isinstance(dest.file_sequence, FileSequence) - def test_image_sequence_not_reset_on_second_write(self) -> None: + def test_file_sequence_not_reset_on_second_write(self) -> None: from griptape_nodes.files.file import File - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - dest = ImageSequenceDestination(macro_path) - dest._on_frame_written(File("workspace/frame_0001.exr")) - first_seq = dest.image_sequence - dest._on_frame_written(File("workspace/frame_0002.exr")) - assert dest.image_sequence is first_seq + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + dest = FileSequenceDestination(macro_path) + dest._on_entry_written(File("workspace/frame_0001.exr")) + first_seq = dest.file_sequence + dest._on_entry_written(File("workspace/frame_0002.exr")) + assert dest.file_sequence is first_seq - def test_image_sequence_uses_locked_macro(self) -> None: + def test_file_sequence_uses_locked_macro(self) -> None: from griptape_nodes.files.file import File - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {"_index": 3}) - dest = ImageSequenceDestination(macro_path) - dest._on_frame_written(File("workspace/frame_0001.exr")) - assert dest.image_sequence is not None - assert dest.image_sequence._frame_macro is macro_path + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {"_index": 3}) + dest = FileSequenceDestination(macro_path) + dest._on_entry_written(File("workspace/frame_0001.exr")) + assert dest.file_sequence is not None + assert dest.file_sequence._entry_macro is macro_path def test_defaults_overwrite_policy(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {}) - dest = ImageSequenceDestination(macro_path) + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) + dest = FileSequenceDestination(macro_path) assert dest._existing_file_policy == ExistingFilePolicy.OVERWRITE assert dest._create_parents is True - def test_frame_write_destination_triggers_on_written_callback(self) -> None: + def test_entry_write_destination_triggers_on_written_callback(self) -> None: from griptape_nodes.files.file import File - from griptape_nodes.files.image_sequence import _FrameWriteDestination + from griptape_nodes.files.file_sequence import _EntryWriteDestination callback_calls: list[File] = [] - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{frame:04}.exr"), {"_index": 1}) - frame_path = MacroPath(macro_path.parsed_macro, {**macro_path.variables, "frame": 1}) + macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) + entry_path = MacroPath(macro_path.parsed_macro, {**macro_path.variables, "entry": 1}) - frame_dest = _FrameWriteDestination( - frame_path, + entry_dest = _EntryWriteDestination( + entry_path, existing_file_policy=ExistingFilePolicy.OVERWRITE, create_parents=True, on_written=callback_calls.append, ) written_file = File("workspace/frame_0001.exr") - # Simulate the callback that fires after a successful write. - frame_dest._on_written(written_file) + entry_dest._on_written(written_file) assert len(callback_calls) == 1 assert callback_calls[0] is written_file @@ -261,19 +260,19 @@ class TestBuildVersionedSequenceDestination: def test_first_version_used_when_parent_missing(self, tmp_path: Path) -> None: missing_parent = tmp_path / "renders_v001" - frame_path = missing_parent / "frame_0000.exr" + entry_path = missing_parent / "frame_0000.exr" resolve_result = GetPathForMacroResultSuccess( result_details="OK", resolved_path=Path("renders_v001/frame_0000.exr"), - absolute_path=frame_path, + absolute_path=entry_path, ) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): dest = build_versioned_sequence_destination(macro) - assert dest._frame_macro.variables["_index"] == 1 + assert dest._entry_macro.variables["_index"] == 1 def test_second_version_used_when_first_parent_exists(self, tmp_path: Path) -> None: existing_parent = tmp_path / "renders_v001" @@ -290,13 +289,13 @@ def test_second_version_used_when_first_parent_exists(self, tmp_path: Path) -> N resolved_path=Path("renders_v002/frame_0000.exr"), absolute_path=missing_parent / "frame_0000.exr", ) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) expected_index = 2 with patch(HANDLE_REQUEST_PATH, side_effect=[resolve_result_1, resolve_result_2]): dest = build_versioned_sequence_destination(macro) - assert dest._frame_macro.variables["_index"] == expected_index + assert dest._entry_macro.variables["_index"] == expected_index def test_raises_when_macro_resolution_fails(self) -> None: failure = GetPathForMacroResultFailure( @@ -304,9 +303,9 @@ def test_raises_when_macro_resolution_fails(self) -> None: failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, missing_variables={"outputs"}, ) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) - with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(ImageSequenceError): + with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(FileSequenceError): build_versioned_sequence_destination(macro) def test_raises_when_all_versions_exhausted(self, tmp_path: Path) -> None: @@ -316,12 +315,12 @@ def test_raises_when_all_versions_exhausted(self, tmp_path: Path) -> None: resolved_path=Path("frame_0000.exr"), absolute_path=existing_parent / "frame_0000.exr", ) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{frame:04}.exr"), {}) + macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) with ( patch(HANDLE_REQUEST_PATH, return_value=resolve_result), - patch("griptape_nodes.files.image_sequence._MAX_VERSION_INDEX", 3), - pytest.raises(ImageSequenceError), + patch("griptape_nodes.files.file_sequence._MAX_VERSION_INDEX", 3), + pytest.raises(FileSequenceError), ): build_versioned_sequence_destination(macro) @@ -332,14 +331,14 @@ def test_locks_index_into_returned_destination_variables(self, tmp_path: Path) - resolved_path=Path("seq_v001/frame_0000.exr"), absolute_path=missing_parent / "frame_0000.exr", ) - macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{frame:04}.exr"), {"extra": "value"}) + macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {"extra": "value"}) with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): dest = build_versioned_sequence_destination(macro) - assert "_index" in dest._frame_macro.variables - assert "extra" in dest._frame_macro.variables - assert "frame" not in dest._frame_macro.variables + assert "_index" in dest._entry_macro.variables + assert "extra" in dest._entry_macro.variables + assert "entry" not in dest._entry_macro.variables def test_existing_file_policy_forwarded(self, tmp_path: Path) -> None: missing_parent = tmp_path / "seq_v001" @@ -348,7 +347,7 @@ def test_existing_file_policy_forwarded(self, tmp_path: Path) -> None: resolved_path=Path("seq_v001/frame_0000.exr"), absolute_path=missing_parent / "frame_0000.exr", ) - macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{frame:04}.exr"), {}) + macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {}) with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): dest = build_versioned_sequence_destination(macro, existing_file_policy=ExistingFilePolicy.FAIL) From 6df2998bf1efbdeb25ef5783831b775eafc1ff44 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 28 May 2026 16:05:00 +0100 Subject: [PATCH 08/22] refactor: simplify upstream destination retrieval in parameter classes --- .../param_components/project_directory_parameter.py | 11 +---------- .../param_components/project_file_parameter.py | 4 ++-- .../project_file_sequence_parameter.py | 11 +---------- .../param_components/project_output_parameter.py | 3 +-- 4 files changed, 5 insertions(+), 24 deletions(-) diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index 2d9ff0104a..0826be37c9 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -22,13 +22,6 @@ _FALLBACK_DIRECTORY_MACRO = "{outputs}/{node_name?:_}{dir_name}_v{_index:03}" -class DirectoryDestinationProvider: - """Protocol for nodes that provide a DirectoryDestination without serializing it over the wire.""" - - @property - def directory_destination(self) -> DirectoryDestination | None: ... - - class ProjectDirectoryParameter(ProjectOutputParameter): """Parameter component for project-aware directory creation. @@ -113,9 +106,7 @@ def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: Raises: ValueError: If an upstream DirectoryDestinationProvider returns None. """ - upstream = self._get_upstream_destination( - DirectoryDestinationProvider, "directory_destination", "DirectoryDestination" - ) + upstream = self._get_upstream_destination("directory_destination", "DirectoryDestination") if upstream is not None: return upstream # type: ignore[return-value] diff --git a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py index f4f3da8aab..2d2c8ff89f 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py @@ -3,7 +3,7 @@ from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter -from griptape_nodes.files.file import FileDestination, FileDestinationProvider +from griptape_nodes.files.file import FileDestination from griptape_nodes.files.project_file import ProjectFileDestination from griptape_nodes.traits.file_system_picker import FileSystemPicker @@ -95,7 +95,7 @@ def build_file(self, **extra_vars: str | int) -> FileDestination: Raises: ValueError: If an upstream FileDestinationProvider is connected but returns None. """ - upstream = self._get_upstream_destination(FileDestinationProvider, "file_destination", "FileDestination") + upstream = self._get_upstream_destination("file_destination", "FileDestination") if upstream is not None: return upstream # type: ignore[return-value] diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 80bb631d6f..9236c7bb45 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -28,13 +28,6 @@ ) -class FileSequenceDestinationProvider: - """Protocol for nodes that provide a FileSequenceDestination without serializing it over the wire.""" - - @property - def file_sequence_destination(self) -> FileSequenceDestination | None: ... - - class ProjectFileSequenceParameter(ProjectOutputParameter): """Parameter component for project-aware file sequence output. @@ -117,9 +110,7 @@ def build_sequence(self, **extra_vars: str | int) -> FileSequenceDestination: ValueError: If an upstream FileSequenceDestinationProvider returns None. FileSequenceError: If no available version index can be found. """ - upstream = self._get_upstream_destination( - FileSequenceDestinationProvider, "file_sequence_destination", "FileSequenceDestination" - ) + upstream = self._get_upstream_destination("file_sequence_destination", "FileSequenceDestination") if upstream is not None: return upstream # type: ignore[return-value] diff --git a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py index d61d6ca12d..a85e57432c 100644 --- a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py @@ -132,7 +132,6 @@ def _reset_to_default( def _get_upstream_destination( self, - provider_type: type, destination_attr: str, destination_type_name: str, ) -> object | None: @@ -149,7 +148,7 @@ def _get_upstream_destination( if conn.target_parameter_name != self._name: continue source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) - if not isinstance(source_node, provider_type): + if source_node is None or not hasattr(source_node, destination_attr): continue destination = getattr(source_node, destination_attr) if destination is None: From 26e76cffb22c256acfc42d67ec5cb635ceb0e467 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 28 May 2026 16:11:56 +0100 Subject: [PATCH 09/22] refactor: update documentation for upstream destination retrieval in parameter classes --- .../project_directory_parameter.py | 9 +- .../project_file_parameter.py | 16 +- .../project_file_sequence_parameter.py | 6 +- .../project_output_parameter.py | 12 +- src/griptape_nodes/files/file_sequence.py | 2 +- .../test_project_output_parameter.py | 191 ++++++++++++++++++ 6 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 tests/unit/exe_types/param_components/test_project_output_parameter.py diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index 0826be37c9..b3e5048410 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -92,10 +92,9 @@ def _make_parameter_traits(self) -> set: def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: """Build a DirectoryDestination from the parameter's current value. - If an upstream node implements DirectoryDestinationProvider, its - DirectoryDestination is retrieved directly. Otherwise the parameter's - string value is used as the directory name, combined with the situation - macro. + If an upstream node exposes a ``directory_destination`` attribute, its + ``DirectoryDestination`` is retrieved directly. Otherwise the parameter's + string value is used as the directory name, combined with the situation macro. Args: **extra_vars: Additional variables for the macro (e.g., sub_dirs="renders") @@ -104,7 +103,7 @@ def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: DirectoryDestination with a versioned MacroPath and baked-in policy. Raises: - ValueError: If an upstream DirectoryDestinationProvider returns None. + ValueError: If an upstream node exposes ``directory_destination`` but returns None. """ upstream = self._get_upstream_destination("directory_destination", "DirectoryDestination") if upstream is not None: diff --git a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py index 2d2c8ff89f..05842b1181 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py @@ -77,14 +77,14 @@ def _make_parameter_traits(self) -> set: def build_file(self, **extra_vars: str | int) -> FileDestination: """Build a FileDestination with a MacroPath from the parameter's current value. - If an upstream node implements FileDestinationProvider (e.g., FileOutputSettings), - its FileDestination is retrieved directly without deserialising from the wire. - An upstream provider that returns None (misconfigured) raises instead of silently - falling back to the default situation, since that fallback hides user intent. + If an upstream node (e.g. FileOutputSettings) exposes a ``file_destination`` + attribute, its ``FileDestination`` is retrieved directly without deserialising + from the wire. A node that exposes the attribute but returns None raises instead + of silently falling back to the default situation, since that fallback hides + user intent. - Otherwise the parameter's string value is parsed into - file_name_base/file_extension, combined with this component's default - situation, and wrapped in a FileDestination. + Otherwise the parameter's string value is combined with this component's + situation to build a ``FileDestination``. Args: **extra_vars: Additional variables for the macro (e.g., sub_dirs="renders") @@ -93,7 +93,7 @@ def build_file(self, **extra_vars: str | int) -> FileDestination: FileDestination with a MacroPath and baked-in write policy for deferred path resolution Raises: - ValueError: If an upstream FileDestinationProvider is connected but returns None. + ValueError: If an upstream node exposes ``file_destination`` but returns None. """ upstream = self._get_upstream_destination("file_destination", "FileDestination") if upstream is not None: diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 9236c7bb45..7ecb2927f2 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -95,8 +95,8 @@ def _parameter_output_type(self) -> str: def build_sequence(self, **extra_vars: str | int) -> FileSequenceDestination: """Build a FileSequenceDestination from the parameter's current value. - If an upstream node implements FileSequenceDestinationProvider, its - FileSequenceDestination is retrieved directly. Otherwise the parameter's + If an upstream node exposes a ``file_sequence_destination`` attribute, its + ``FileSequenceDestination`` is retrieved directly. Otherwise the parameter's string value (filename or #### pattern) is parsed and combined with the situation macro. @@ -107,7 +107,7 @@ def build_sequence(self, **extra_vars: str | int) -> FileSequenceDestination: FileSequenceDestination with a versioned MacroPath and baked-in policy. Raises: - ValueError: If an upstream FileSequenceDestinationProvider returns None. + ValueError: If an upstream node exposes ``file_sequence_destination`` but returns None. FileSequenceError: If no available version index can be found. """ upstream = self._get_upstream_destination("file_sequence_destination", "FileSequenceDestination") diff --git a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py index a85e57432c..f04143a804 100644 --- a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py @@ -135,10 +135,18 @@ def _get_upstream_destination( destination_attr: str, destination_type_name: str, ) -> object | None: - """Return the upstream provider's destination, or None if no provider is connected. + """Return the destination from the first upstream node that exposes ``destination_attr``. + + Returns None if no connected node has the attribute. + + Args: + destination_attr: Attribute name to look for on the upstream node + (e.g. ``'file_destination'``). + destination_type_name: Human-readable type name used in error messages + (e.g. ``'FileDestination'``). Raises: - ValueError: If a provider is connected but returns None. + ValueError: If a connected node exposes ``destination_attr`` but returns None. """ result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) if not isinstance(result, ListConnectionsForNodeResultSuccess): diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index ee93b37f28..126ff5e02b 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -70,7 +70,7 @@ def pattern(self) -> str: The ``{entry:NN}`` macro variable is replaced with a run of ``#`` characters matching the padding width. - Example: ``"{outputs}/renders_v001/frame_####.exr"`` + Example: ``"{outputs}/dialogue_v001/####.wav"`` """ return entry_macro_to_hash_pattern(self.location) diff --git a/tests/unit/exe_types/param_components/test_project_output_parameter.py b/tests/unit/exe_types/param_components/test_project_output_parameter.py new file mode 100644 index 0000000000..1ca5b2df93 --- /dev/null +++ b/tests/unit/exe_types/param_components/test_project_output_parameter.py @@ -0,0 +1,191 @@ +"""Unit tests for ProjectOutputParameter._get_upstream_destination.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from griptape_nodes.exe_types.core_types import ParameterMode +from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter +from griptape_nodes.retained_mode.events.connection_events import ( + IncomingConnection, + ListConnectionsForNodeResultFailure, + ListConnectionsForNodeResultSuccess, +) + +HANDLE_REQUEST_PATH = "griptape_nodes.exe_types.param_components.project_output_parameter.GriptapeNodes.handle_request" +OBJECT_MANAGER_PATH = "griptape_nodes.exe_types.param_components.project_output_parameter.GriptapeNodes.ObjectManager" + + +class _ConcreteParam(ProjectOutputParameter): + """Minimal concrete subclass for testing the base class.""" + + @property + def _settings_node_type(self) -> str: + return "TestSettings" + + @property + def _settings_value_param_name(self) -> str: + return "value" + + @property + def _settings_source_param_name(self) -> str: + return "test_destination" + + @property + def _parameter_output_type(self) -> str: + return "str" + + +def _make_param(param_name: str = "output") -> _ConcreteParam: + mock_node = MagicMock() + mock_node.name = "MyNode" + return _ConcreteParam(mock_node, param_name, default_value="default.txt", situation="save_node_output") + + +def _make_connections_result(*connections: IncomingConnection) -> ListConnectionsForNodeResultSuccess: + return ListConnectionsForNodeResultSuccess( + result_details="ok", + incoming_connections=list(connections), + outgoing_connections=[], + ) + + +def _make_connection( + target_param: str, + source_node: str = "UpstreamNode", + source_param: str = "test_destination", +) -> IncomingConnection: + return IncomingConnection( + source_node_name=source_node, + source_parameter_name=source_param, + target_parameter_name=target_param, + ) + + +class TestGetUpstreamDestination: + """Tests for _get_upstream_destination, which finds an upstream provider via hasattr.""" + + def test_returns_none_when_list_connections_fails(self) -> None: + param = _make_param() + failure = ListConnectionsForNodeResultFailure(result_details="error") + + with patch(HANDLE_REQUEST_PATH, return_value=failure): + result = param._get_upstream_destination("test_destination", "TestDestination") + + assert result is None + + def test_returns_none_when_no_incoming_connections(self) -> None: + param = _make_param() + success = _make_connections_result() + + with patch(HANDLE_REQUEST_PATH, return_value=success): + result = param._get_upstream_destination("test_destination", "TestDestination") + + assert result is None + + def test_returns_none_when_connection_targets_different_parameter(self) -> None: + param = _make_param("output") + conn = _make_connection(target_param="other_param") + success = _make_connections_result(conn) + mock_source = MagicMock(spec=[]) # no attributes + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(OBJECT_MANAGER_PATH) as mock_om, + ): + mock_om.return_value.attempt_get_object_by_name.return_value = mock_source + result = param._get_upstream_destination("test_destination", "TestDestination") + + assert result is None + + def test_returns_none_when_source_node_not_found(self) -> None: + param = _make_param() + conn = _make_connection(target_param="output") + success = _make_connections_result(conn) + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(OBJECT_MANAGER_PATH) as mock_om, + ): + mock_om.return_value.attempt_get_object_by_name.return_value = None + result = param._get_upstream_destination("test_destination", "TestDestination") + + assert result is None + + def test_returns_none_when_source_node_lacks_attribute(self) -> None: + param = _make_param() + conn = _make_connection(target_param="output") + success = _make_connections_result(conn) + mock_source = MagicMock(spec=[]) # no attributes at all + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(OBJECT_MANAGER_PATH) as mock_om, + ): + mock_om.return_value.attempt_get_object_by_name.return_value = mock_source + result = param._get_upstream_destination("test_destination", "TestDestination") + + assert result is None + + def test_returns_destination_when_source_has_attribute(self) -> None: + param = _make_param() + conn = _make_connection(target_param="output") + success = _make_connections_result(conn) + expected_dest = MagicMock() + mock_source = MagicMock(spec=["test_destination"]) + mock_source.test_destination = expected_dest + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(OBJECT_MANAGER_PATH) as mock_om, + ): + mock_om.return_value.attempt_get_object_by_name.return_value = mock_source + result = param._get_upstream_destination("test_destination", "TestDestination") + + assert result is expected_dest + + def test_raises_when_provider_attribute_returns_none(self) -> None: + param = _make_param() + conn = _make_connection(target_param="output") + success = _make_connections_result(conn) + mock_source = MagicMock(spec=["test_destination"]) + mock_source.test_destination = None + + with patch(HANDLE_REQUEST_PATH, return_value=success), patch(OBJECT_MANAGER_PATH) as mock_om: + mock_om.return_value.attempt_get_object_by_name.return_value = mock_source + with pytest.raises(ValueError, match="UpstreamNode"): + param._get_upstream_destination("test_destination", "TestDestination") + + def test_skips_non_provider_and_returns_provider_destination(self) -> None: + """A non-provider connection followed by a provider: skips first, returns second.""" + param = _make_param() + conn_non_provider = _make_connection(target_param="output", source_node="PlainNode") + conn_provider = _make_connection(target_param="output", source_node="ProviderNode") + success = _make_connections_result(conn_non_provider, conn_provider) + + expected_dest = MagicMock() + plain_source = MagicMock(spec=[]) # no test_destination + provider_source = MagicMock(spec=["test_destination"]) + provider_source.test_destination = expected_dest + + def get_node(name: str) -> MagicMock: + return plain_source if name == "PlainNode" else provider_source + + with ( + patch(HANDLE_REQUEST_PATH, return_value=success), + patch(OBJECT_MANAGER_PATH) as mock_om, + ): + mock_om.return_value.attempt_get_object_by_name.side_effect = get_node + result = param._get_upstream_destination("test_destination", "TestDestination") + + assert result is expected_dest + + def test_allowed_modes_default(self) -> None: + param = _make_param() + assert param._allowed_modes == {ParameterMode.INPUT, ParameterMode.PROPERTY} + + def test_custom_allowed_modes(self) -> None: + mock_node = MagicMock() + mock_node.name = "N" + param = _ConcreteParam(mock_node, "out", default_value="x", situation="s", allowed_modes={ParameterMode.OUTPUT}) + assert param._allowed_modes == {ParameterMode.OUTPUT} From 12a103af9dea61f18ae370a5ef106e5c6b765ce1 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Mon, 1 Jun 2026 11:34:07 +0100 Subject: [PATCH 10/22] feat: enhance documentation and add scan method for FileSequence class --- .../project_directory_parameter.py | 6 +- .../project_file_parameter.py | 6 +- .../project_file_sequence_parameter.py | 7 +- .../project_output_parameter.py | 7 +- src/griptape_nodes/files/directory.py | 7 +- src/griptape_nodes/files/file_sequence.py | 83 +++++++++-- tests/unit/files/test_file_sequence.py | 140 +++++++++++++++++- 7 files changed, 238 insertions(+), 18 deletions(-) diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index b3e5048410..c3ed58026a 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -1,4 +1,8 @@ -"""ProjectDirectoryParameter - parameter component for project-aware directory creation.""" +"""ProjectDirectoryParameter - parameter component for project-aware directory creation. + +Provisions a versioned output directory via situation-based macro routing and exposes it as a +DirectoryDestination. Falls back to a sensible default when no situation is configured. +""" import logging diff --git a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py index 05842b1181..8c099c0353 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_parameter.py @@ -1,4 +1,8 @@ -"""ProjectFileParameter - parameter component for project-aware file saving.""" +"""ProjectFileParameter - parameter component for project-aware file saving. + +Wraps a MacroPath into a FileDestination with a baked-in write policy, deferring +path resolution to execution time via the situation-based macro system. +""" from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 7ecb2927f2..73ce3dee4f 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -1,4 +1,9 @@ -"""ProjectFileSequenceParameter - parameter component for project-aware file sequence output.""" +"""ProjectFileSequenceParameter - parameter component for project-aware file sequence output. + +Provisions a versioned output directory and exposes a FileSequenceDestination whose entry() +method returns a per-frame FileDestination. Situation-based macro routing determines the +directory layout; falls back to a sensible default when no situation is configured. +""" import logging diff --git a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py index f04143a804..2d0d0ece69 100644 --- a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py @@ -1,4 +1,9 @@ -"""ProjectOutputParameter - shared base class for project-aware output parameter components.""" +"""ProjectOutputParameter - shared base class for project-aware output parameter components. + +Handles common wiring: adding a file-path parameter, attaching an open-in-explorer button, +and emitting the resolved path via node messages. Subclasses supply the destination-building +logic for their specific output type (file, directory, or sequence). +""" from __future__ import annotations diff --git a/src/griptape_nodes/files/directory.py b/src/griptape_nodes/files/directory.py index 4f8d0632ac..fb064db3a9 100644 --- a/src/griptape_nodes/files/directory.py +++ b/src/griptape_nodes/files/directory.py @@ -1,4 +1,9 @@ -"""Directory - path-like object for directory operations via the retained mode API.""" +"""Directory - a macro-path handle for project directories. + +Supports I/O-free path inspection and deferred write via DirectoryDestination. +Versioned directories (v001, v002 …) are provisioned by build_versioned_directory_destination, +which probes the filesystem via the retained mode API to find the first unused index. +""" from __future__ import annotations diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index 126ff5e02b..ea35be5fe7 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -1,4 +1,10 @@ -"""FileSequence - a collection of files identified by an entry-number pattern.""" +"""FileSequence and FileSequenceDestination for numbered file collections (frames, audio takes, etc.). + +Entry macros like ``{outputs}/dialogue_v{_index:03}/{entry:04}.wav`` drive both reading +(FileSequence) and writing (FileSequenceDestination). Scanning delegates to the engine via +ScanSequencesRequest; versioned destinations lock a free directory index via +build_versioned_sequence_destination. +""" from __future__ import annotations @@ -6,10 +12,18 @@ from pathlib import Path from typing import TYPE_CHECKING +from fileseq.constants import PAD_STYLE_HASH1 +from fileseq.filesequence import FileSequence as _FSeq + +from griptape_nodes.common.sequences import MissingItemPolicy, Sequence from griptape_nodes.files.directory import Directory from griptape_nodes.files.file import File, FileDestination from griptape_nodes.files.project_file import ProjectFileDestination -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.os_events import ( + ExistingFilePolicy, + ScanSequencesRequest, + ScanSequencesResultSuccess, +) from griptape_nodes.retained_mode.events.project_events import ( GetPathForMacroRequest, GetPathForMacroResultSuccess, @@ -21,7 +35,6 @@ from collections.abc import Callable _ENTRY_VAR_NAME = "entry" -_HASH_PATTERN = re.compile(r"#+") _ENTRY_MACRO_PATTERN = re.compile(r"\{entry(?::(\d+))?\}") _MAX_VERSION_INDEX = 9999 @@ -96,6 +109,45 @@ def entry(self, entry_number: int) -> File: variables = {**self._entry_macro.variables, _ENTRY_VAR_NAME: entry_number} return File(MacroPath(self._entry_macro.parsed_macro, variables)) + def scan( + self, + *, + policy: MissingItemPolicy = MissingItemPolicy.SPLIT, + start: int | None = None, + end: int | None = None, + ) -> list[Sequence]: + """Scan the sequence directory and return what's on disk. + + Args: + policy: How to handle gaps in the number range. Defaults to SPLIT. + start: Optional lower bound (inclusive) for the active subset. + end: Optional upper bound (inclusive) for the active subset. + + Returns: + List of Sequence objects. Empty if the directory cannot be resolved + or contains no matching files. + """ + probe_vars = {**self._entry_macro.variables, _ENTRY_VAR_NAME: 0} + resolve_result = GriptapeNodes.handle_request( + GetPathForMacroRequest(parsed_macro=self._entry_macro.parsed_macro, variables=probe_vars) + ) + if not isinstance(resolve_result, GetPathForMacroResultSuccess): + return [] + resolved_dir = str(resolve_result.absolute_path.parent) + filename_pattern = Path(self.pattern).name + scan_result = GriptapeNodes.handle_request( + ScanSequencesRequest( + directory=resolved_dir, + pattern=filename_pattern, + policy=policy, + start_number=start, + end_number=end, + ) + ) + if not isinstance(scan_result, ScanSequencesResultSuccess): + return [] + return scan_result.sequences + class _EntryWriteDestination(ProjectFileDestination): """FileDestination subclass that fires a callback after each successful write.""" @@ -248,20 +300,27 @@ def build_versioned_sequence_destination( def hash_pattern_to_entry_macro(pattern: str) -> str: - """Convert a #### entry pattern to a macro template with {entry:NN} syntax. + """Convert a sequence token pattern to a macro template with {entry:NN} syntax. + + Accepts all fileseq token forms: ``####``, ``%04d``, ``@@@@``, ``$F4``. Args: - pattern: Pattern string like ``"render_####.exr"`` or ``"frame_##.png"``. + pattern: Pattern string like ``"render_####.exr"``, ``"render_%04d.exr"``, + or a full path like ``"{outputs}/renders/render_####.exr"``. Returns: - Macro template string like ``"render_{entry:04}.exr"``. + Macro template string with the token replaced by ``{entry:NN}``. Returns + the input unchanged if no sequence token is found. """ - - def replace_hashes(match: re.Match) -> str: - width = len(match.group()) - return f"{{entry:{width:02d}}}" - - return _HASH_PATTERN.sub(replace_hashes, pattern) + path = Path(pattern) + fseq = _FSeq(path.name, pad_style=PAD_STYLE_HASH1) + width = fseq.zfill() + if width == 0: + return pattern + entry_part = f"{{entry:{width:02}}}" + new_name = fseq.basename() + entry_part + fseq.extension() + parent = str(path.parent) + return str(Path(parent) / new_name) if parent != "." else new_name def entry_macro_to_hash_pattern(template: str) -> str: diff --git a/tests/unit/files/test_file_sequence.py b/tests/unit/files/test_file_sequence.py index d1f991a599..136b762d6f 100644 --- a/tests/unit/files/test_file_sequence.py +++ b/tests/unit/files/test_file_sequence.py @@ -6,6 +6,7 @@ import pytest from griptape_nodes.common.macro_parser import ParsedMacro +from griptape_nodes.common.sequences import MissingItemPolicy, Sequence, SequenceEntry from griptape_nodes.files.file_sequence import ( FileSequence, FileSequenceDestination, @@ -14,7 +15,13 @@ entry_macro_to_hash_pattern, hash_pattern_to_entry_macro, ) -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.os_events import ( + ExistingFilePolicy, + ScanSequencesRequest, + ScanSequencesResultFailure, + ScanSequencesResultSuccess, + SequenceScanFailureReason, +) from griptape_nodes.retained_mode.events.project_events import ( GetPathForMacroResultFailure, GetPathForMacroResultSuccess, @@ -69,6 +76,21 @@ def test_roundtrip_macro_to_hash_to_macro(self) -> None: original = "render_{entry:04}.exr" assert hash_pattern_to_entry_macro(entry_macro_to_hash_pattern(original)) == original + def test_hash_to_entry_macro_printf_pattern(self) -> None: + assert hash_pattern_to_entry_macro("render_%04d.exr") == "render_{entry:04}.exr" + + def test_hash_to_entry_macro_full_path_with_parent_dir(self) -> None: + assert ( + hash_pattern_to_entry_macro("{outputs}/renders/render_####.exr") + == "{outputs}/renders/render_{entry:04}.exr" + ) + + def test_hash_to_entry_macro_printf_in_full_path(self) -> None: + assert ( + hash_pattern_to_entry_macro("{outputs}/renders/render_%04d.exr") + == "{outputs}/renders/render_{entry:04}.exr" + ) + class TestFileSequenceConstructor: """Tests that FileSequence constructor stores the entry macro without I/O.""" @@ -255,6 +277,122 @@ def test_entry_write_destination_triggers_on_written_callback(self) -> None: assert callback_calls[0] is written_file +class TestFileSequenceScan: + """Tests for FileSequence.scan(). + + scan() makes two handle_request calls: + 1. GetPathForMacroRequest — resolves the macro to an absolute directory path. + 2. ScanSequencesRequest — delegates the actual filesystem scan to the engine. + """ + + _ABS_DIR = "/abs/work/frames" + _TEMPLATE = "{outputs}/frames/frame_{entry:04}.exr" + + def _path_success(self) -> GetPathForMacroResultSuccess: + return GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("frames/frame_0000.exr"), + absolute_path=Path(f"{self._ABS_DIR}/frame_0000.exr"), + ) + + def _scan_success(self, sequences: list[Sequence] | None = None) -> ScanSequencesResultSuccess: + seqs = sequences or [] + return ScanSequencesResultSuccess( + result_details="ok", + sequences=seqs, + has_entries=any(s.entries for s in seqs), + directory_had_matching_files=bool(seqs), + ) + + def _make_sequence(self) -> Sequence: + return Sequence( + entries=[SequenceEntry(number=1, padded_number="0001", path=f"{self._ABS_DIR}/frame_0001.exr")], + first=1, + last=1, + discovered_first=1, + discovered_last=1, + padding=4, + pattern="frame_####.exr", + directory=self._ABS_DIR, + policy=MissingItemPolicy.SPLIT, + present_numbers={1}, + ) + + def test_returns_empty_list_when_macro_resolution_fails(self) -> None: + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + failure = GetPathForMacroResultFailure( + result_details="missing outputs", + failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, + ) + with patch(HANDLE_REQUEST_PATH, return_value=failure): + assert seq.scan() == [] + + def test_returns_empty_list_when_scan_request_fails(self) -> None: + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + scan_failure = ScanSequencesResultFailure( + result_details="listing error", + failure_reason=SequenceScanFailureReason.INVALID_TEMPLATE, + ) + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), scan_failure]): + assert seq.scan() == [] + + def test_returns_sequences_on_success(self) -> None: + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + expected = [self._make_sequence()] + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success(expected)]): + result = seq.scan() + assert result == expected + + def test_returns_empty_list_when_no_sequences_found(self) -> None: + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]): + assert seq.scan() == [] + + def test_dispatches_resolved_directory_to_scan_request(self) -> None: + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + seq.scan() + scan_request = mock_handle.call_args_list[1][0][0] + assert isinstance(scan_request, ScanSequencesRequest) + assert scan_request.directory == self._ABS_DIR + + def test_dispatches_filename_only_pattern_to_scan_request(self) -> None: + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + seq.scan() + scan_request = mock_handle.call_args_list[1][0][0] + assert isinstance(scan_request, ScanSequencesRequest) + assert scan_request.pattern == "frame_####.exr" + + def test_forwards_policy_to_scan_request(self) -> None: + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + seq.scan(policy=MissingItemPolicy.SKIP) + scan_request = mock_handle.call_args_list[1][0][0] + assert isinstance(scan_request, ScanSequencesRequest) + assert scan_request.policy == MissingItemPolicy.SKIP + + def test_forwards_start_and_end_to_scan_request(self) -> None: + start, end = 2, 10 + seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + seq.scan(start=start, end=end) + scan_request = mock_handle.call_args_list[1][0][0] + assert isinstance(scan_request, ScanSequencesRequest) + assert scan_request.start_number == start + assert scan_request.end_number == end + + def test_probe_macro_includes_entry_zero(self) -> None: + locked_index = 3 + macro_path = MacroPath(ParsedMacro(self._TEMPLATE), {"_index": locked_index}) + seq = FileSequence(macro_path) + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + seq.scan() + path_request = mock_handle.call_args_list[0][0][0] + assert path_request.variables["entry"] == 0 + assert path_request.variables["_index"] == locked_index + + class TestBuildVersionedSequenceDestination: """Tests for build_versioned_sequence_destination.""" From 749b45a5479783e5a3866fc228c3a72abd45609c Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Mon, 1 Jun 2026 12:19:33 +0100 Subject: [PATCH 11/22] refactor: replace Path with PurePosixPath for consistent path handling --- src/griptape_nodes/files/file_sequence.py | 10 ++-- tests/unit/files/test_file_sequence.py | 57 +++++++++++++---------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index ea35be5fe7..649852cd86 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -9,7 +9,7 @@ from __future__ import annotations import re -from pathlib import Path +from pathlib import PurePosixPath from typing import TYPE_CHECKING from fileseq.constants import PAD_STYLE_HASH1 @@ -94,7 +94,7 @@ def directory(self) -> Directory: No I/O is performed; the directory path is derived from the macro template by stripping the filename component. """ - dir_location = str(Path(self.location).parent) + dir_location = str(PurePosixPath(self.location).parent) return Directory(dir_location) def entry(self, entry_number: int) -> File: @@ -134,7 +134,7 @@ def scan( if not isinstance(resolve_result, GetPathForMacroResultSuccess): return [] resolved_dir = str(resolve_result.absolute_path.parent) - filename_pattern = Path(self.pattern).name + filename_pattern = PurePosixPath(self.pattern).name scan_result = GriptapeNodes.handle_request( ScanSequencesRequest( directory=resolved_dir, @@ -312,7 +312,7 @@ def hash_pattern_to_entry_macro(pattern: str) -> str: Macro template string with the token replaced by ``{entry:NN}``. Returns the input unchanged if no sequence token is found. """ - path = Path(pattern) + path = PurePosixPath(pattern) fseq = _FSeq(path.name, pad_style=PAD_STYLE_HASH1) width = fseq.zfill() if width == 0: @@ -320,7 +320,7 @@ def hash_pattern_to_entry_macro(pattern: str) -> str: entry_part = f"{{entry:{width:02}}}" new_name = fseq.basename() + entry_part + fseq.extension() parent = str(path.parent) - return str(Path(parent) / new_name) if parent != "." else new_name + return f"{parent}/{new_name}" if parent != "." else new_name def entry_macro_to_hash_pattern(template: str) -> str: diff --git a/tests/unit/files/test_file_sequence.py b/tests/unit/files/test_file_sequence.py index 136b762d6f..6498281626 100644 --- a/tests/unit/files/test_file_sequence.py +++ b/tests/unit/files/test_file_sequence.py @@ -285,14 +285,13 @@ class TestFileSequenceScan: 2. ScanSequencesRequest — delegates the actual filesystem scan to the engine. """ - _ABS_DIR = "/abs/work/frames" _TEMPLATE = "{outputs}/frames/frame_{entry:04}.exr" - def _path_success(self) -> GetPathForMacroResultSuccess: + def _path_success(self, abs_dir: Path) -> GetPathForMacroResultSuccess: return GetPathForMacroResultSuccess( result_details="OK", resolved_path=Path("frames/frame_0000.exr"), - absolute_path=Path(f"{self._ABS_DIR}/frame_0000.exr"), + absolute_path=abs_dir / "frame_0000.exr", ) def _scan_success(self, sequences: list[Sequence] | None = None) -> ScanSequencesResultSuccess: @@ -304,16 +303,16 @@ def _scan_success(self, sequences: list[Sequence] | None = None) -> ScanSequence directory_had_matching_files=bool(seqs), ) - def _make_sequence(self) -> Sequence: + def _make_sequence(self, abs_dir: Path) -> Sequence: return Sequence( - entries=[SequenceEntry(number=1, padded_number="0001", path=f"{self._ABS_DIR}/frame_0001.exr")], + entries=[SequenceEntry(number=1, padded_number="0001", path=str(abs_dir / "frame_0001.exr"))], first=1, last=1, discovered_first=1, discovered_last=1, padding=4, pattern="frame_####.exr", - directory=self._ABS_DIR, + directory=str(abs_dir), policy=MissingItemPolicy.SPLIT, present_numbers={1}, ) @@ -327,66 +326,76 @@ def test_returns_empty_list_when_macro_resolution_fails(self) -> None: with patch(HANDLE_REQUEST_PATH, return_value=failure): assert seq.scan() == [] - def test_returns_empty_list_when_scan_request_fails(self) -> None: + def test_returns_empty_list_when_scan_request_fails(self, tmp_path: Path) -> None: seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) scan_failure = ScanSequencesResultFailure( result_details="listing error", failure_reason=SequenceScanFailureReason.INVALID_TEMPLATE, ) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), scan_failure]): + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), scan_failure]): assert seq.scan() == [] - def test_returns_sequences_on_success(self) -> None: + def test_returns_sequences_on_success(self, tmp_path: Path) -> None: seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - expected = [self._make_sequence()] - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success(expected)]): + expected = [self._make_sequence(tmp_path)] + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success(expected)]): result = seq.scan() assert result == expected - def test_returns_empty_list_when_no_sequences_found(self) -> None: + def test_returns_empty_list_when_no_sequences_found(self, tmp_path: Path) -> None: seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]): + with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()]): assert seq.scan() == [] - def test_dispatches_resolved_directory_to_scan_request(self) -> None: + def test_dispatches_resolved_directory_to_scan_request(self, tmp_path: Path) -> None: seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + with patch( + HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] + ) as mock_handle: seq.scan() scan_request = mock_handle.call_args_list[1][0][0] assert isinstance(scan_request, ScanSequencesRequest) - assert scan_request.directory == self._ABS_DIR + assert scan_request.directory == str(tmp_path) - def test_dispatches_filename_only_pattern_to_scan_request(self) -> None: + def test_dispatches_filename_only_pattern_to_scan_request(self, tmp_path: Path) -> None: seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + with patch( + HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] + ) as mock_handle: seq.scan() scan_request = mock_handle.call_args_list[1][0][0] assert isinstance(scan_request, ScanSequencesRequest) assert scan_request.pattern == "frame_####.exr" - def test_forwards_policy_to_scan_request(self) -> None: + def test_forwards_policy_to_scan_request(self, tmp_path: Path) -> None: seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + with patch( + HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] + ) as mock_handle: seq.scan(policy=MissingItemPolicy.SKIP) scan_request = mock_handle.call_args_list[1][0][0] assert isinstance(scan_request, ScanSequencesRequest) assert scan_request.policy == MissingItemPolicy.SKIP - def test_forwards_start_and_end_to_scan_request(self) -> None: + def test_forwards_start_and_end_to_scan_request(self, tmp_path: Path) -> None: start, end = 2, 10 seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + with patch( + HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] + ) as mock_handle: seq.scan(start=start, end=end) scan_request = mock_handle.call_args_list[1][0][0] assert isinstance(scan_request, ScanSequencesRequest) assert scan_request.start_number == start assert scan_request.end_number == end - def test_probe_macro_includes_entry_zero(self) -> None: + def test_probe_macro_includes_entry_zero(self, tmp_path: Path) -> None: locked_index = 3 macro_path = MacroPath(ParsedMacro(self._TEMPLATE), {"_index": locked_index}) seq = FileSequence(macro_path) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(), self._scan_success()]) as mock_handle: + with patch( + HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] + ) as mock_handle: seq.scan() path_request = mock_handle.call_args_list[0][0][0] assert path_request.variables["entry"] == 0 From 1cb89dcbab36536bea64933c5b517483e0eb1d6b Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Mon, 1 Jun 2026 14:21:23 +0100 Subject: [PATCH 12/22] fix: directory handling in FileSequence to preserve locked variables --- src/griptape_nodes/files/file_sequence.py | 23 ++++++++++++++++------- tests/unit/files/test_file_sequence.py | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index 649852cd86..cd73b372f5 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -15,6 +15,7 @@ from fileseq.constants import PAD_STYLE_HASH1 from fileseq.filesequence import FileSequence as _FSeq +from griptape_nodes.common.macro_parser import ParsedMacro from griptape_nodes.common.sequences import MissingItemPolicy, Sequence from griptape_nodes.files.directory import Directory from griptape_nodes.files.file import File, FileDestination @@ -70,9 +71,14 @@ def __init__(self, entry_macro: MacroPath) -> None: @property def location(self) -> str: - """Return the raw macro template for wire serialisation. + """Return the raw macro template for this sequence. - Example: ``"{outputs}/dialogue_v001/{entry:04}.wav"`` + Unresolved placeholders (including ``{_index}`` when versioning is in + effect) remain in the returned string. Use ``_entry_macro`` directly + when a self-contained (template + locked variables) representation is + needed for serialisation. + + Example: ``"{outputs}/dialogue_v{_index:03}/{entry:04}.wav"`` """ return self._entry_macro.parsed_macro.template @@ -81,9 +87,10 @@ def pattern(self) -> str: """Return the #### notation form of this sequence's entry pattern. The ``{entry:NN}`` macro variable is replaced with a run of ``#`` - characters matching the padding width. + characters matching the padding width. Other placeholders (e.g. + ``{_index}``) remain unresolved. - Example: ``"{outputs}/dialogue_v001/####.wav"`` + Example: ``"{outputs}/dialogue_v{_index:03}/####.wav"`` """ return entry_macro_to_hash_pattern(self.location) @@ -92,10 +99,12 @@ def directory(self) -> Directory: """Return the containing directory as a Directory. No I/O is performed; the directory path is derived from the macro - template by stripping the filename component. + template by stripping the filename component. The locked variables + (e.g. ``_index``) are preserved so the returned Directory can be resolved. """ - dir_location = str(PurePosixPath(self.location).parent) - return Directory(dir_location) + dir_template = str(PurePosixPath(self.location).parent) + dir_variables = {k: v for k, v in self._entry_macro.variables.items() if k != _ENTRY_VAR_NAME} + return Directory(MacroPath(ParsedMacro(dir_template), dir_variables)) def entry(self, entry_number: int) -> File: """Return a File for reading a specific entry. diff --git a/tests/unit/files/test_file_sequence.py b/tests/unit/files/test_file_sequence.py index 6498281626..3684a71ca5 100644 --- a/tests/unit/files/test_file_sequence.py +++ b/tests/unit/files/test_file_sequence.py @@ -150,6 +150,23 @@ def test_directory_returns_parent_of_template(self) -> None: directory = seq.directory assert directory.location == "{outputs}/frames" + def test_directory_preserves_locked_index_variable(self) -> None: + locked_index = 2 + template = "{outputs}/renders_v{_index:03}/frame_{entry:04}.exr" + macro_path = MacroPath(ParsedMacro(template), {"_index": locked_index}) + seq = FileSequence(macro_path) + directory = seq.directory + assert isinstance(directory._dir_path, MacroPath) + assert directory._dir_path.variables["_index"] == locked_index + + def test_directory_does_not_carry_entry_variable(self) -> None: + template = "{outputs}/frames/frame_{entry:04}.exr" + macro_path = MacroPath(ParsedMacro(template), {"_index": 1, "entry": 5}) + seq = FileSequence(macro_path) + directory = seq.directory + assert isinstance(directory._dir_path, MacroPath) + assert "entry" not in directory._dir_path.variables + def test_directory_no_io_performed(self) -> None: macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) with patch(HANDLE_REQUEST_PATH) as mock_handle: From ff099ca11f3679239a6630d50d6d02b64d4a6d83 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Mon, 1 Jun 2026 16:33:01 +0100 Subject: [PATCH 13/22] fix: scan() pattern and TOCTOU race in versioned sequence/directory scan() was deriving the fileseq pattern from the unresolved macro template, leaving {_index:03} literally in the pattern and causing every scan to return []. Now derives from the resolved absolute path. build_versioned_sequence_destination and _create_with_versioning both had a race between exists()-check and mkdir(); now mkdir(exist_ok=False) is used to atomically claim the slot, with FileExistsError caught to retry the next index. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/griptape_nodes/files/directory.py | 5 ++++- src/griptape_nodes/files/file_sequence.py | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/griptape_nodes/files/directory.py b/src/griptape_nodes/files/directory.py index fb064db3a9..a3259ee455 100644 --- a/src/griptape_nodes/files/directory.py +++ b/src/griptape_nodes/files/directory.py @@ -176,7 +176,10 @@ def _create_with_versioning(self) -> Directory: absolute_path = resolve_result.absolute_path if not absolute_path.exists(): - absolute_path.mkdir(parents=self._create_parents, exist_ok=False) + try: + absolute_path.mkdir(parents=self._create_parents, exist_ok=False) + except FileExistsError: + continue locked_macro = MacroPath(macro_path.parsed_macro, variables) return _map_to_macro_directory(absolute_path, locked_macro) diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index cd73b372f5..495e4a4dce 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -143,7 +143,11 @@ def scan( if not isinstance(resolve_result, GetPathForMacroResultSuccess): return [] resolved_dir = str(resolve_result.absolute_path.parent) - filename_pattern = PurePosixPath(self.pattern).name + filename_template = PurePosixPath(self.location).name + entry_match = _ENTRY_MACRO_PATTERN.search(filename_template) + entry_width = int(entry_match.group(1)) if entry_match and entry_match.group(1) else 4 + entry_zero_str = format(0, f"0{entry_width}d") + filename_pattern = resolve_result.absolute_path.name.replace(entry_zero_str, "#" * entry_width, 1) scan_result = GriptapeNodes.handle_request( ScanSequencesRequest( directory=resolved_dir, @@ -296,6 +300,10 @@ def build_versioned_sequence_destination( parent_dir = resolve_result.absolute_path.parent if not parent_dir.exists(): + try: + parent_dir.mkdir(parents=create_parents, exist_ok=False) + except FileExistsError: + continue locked_vars = {**entry_macro.variables, "_index": index} locked_macro = MacroPath(entry_macro.parsed_macro, locked_vars) return FileSequenceDestination( From 257a69200743ace1cae5f42137a12f99acf066f2 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Mon, 1 Jun 2026 16:33:11 +0100 Subject: [PATCH 14/22] fix: derive file_name_base from original filename, not entry-macro form hash_pattern_to_entry_macro was called before FilenameParts.from_filename, causing stems like render_{entry:04} to appear literally in directory names. Call from_filename on the original filename instead. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../param_components/project_file_sequence_parameter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 73ce3dee4f..814afb86c0 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -14,7 +14,6 @@ from griptape_nodes.files.file_sequence import ( FileSequenceDestination, build_versioned_sequence_destination, - hash_pattern_to_entry_macro, ) from griptape_nodes.files.path_utils import FilenameParts from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY @@ -160,8 +159,7 @@ def _build_sequence_destination_from_situation( existing_file_policy = ExistingFilePolicy.OVERWRITE create_parents = True - normalized_filename = hash_pattern_to_entry_macro(filename) - parts = FilenameParts.from_filename(normalized_filename) + parts = FilenameParts.from_filename(filename) variables: dict[str, str | int] = { "file_name_base": parts.stem, From bdb3dcafc349f305323303b96e88b15e59b28001 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 4 Jun 2026 16:38:41 +0100 Subject: [PATCH 15/22] feat: implement versioning logic using GetNextVersionIndexRequest in Directory and FileSequence components --- .../project_output_parameter.py | 7 +- src/griptape_nodes/files/directory.py | 56 +++++----- src/griptape_nodes/files/file_sequence.py | 54 +++++----- tests/unit/files/test_directory.py | 75 +++++++++---- tests/unit/files/test_file_sequence.py | 101 +++++++----------- 5 files changed, 148 insertions(+), 145 deletions(-) diff --git a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py index 2d0d0ece69..f04143a804 100644 --- a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py @@ -1,9 +1,4 @@ -"""ProjectOutputParameter - shared base class for project-aware output parameter components. - -Handles common wiring: adding a file-path parameter, attaching an open-in-explorer button, -and emitting the resolved path via node messages. Subclasses supply the destination-building -logic for their specific output type (file, directory, or sequence). -""" +"""ProjectOutputParameter - shared base class for project-aware output parameter components.""" from __future__ import annotations diff --git a/src/griptape_nodes/files/directory.py b/src/griptape_nodes/files/directory.py index a3259ee455..3ab3cc3519 100644 --- a/src/griptape_nodes/files/directory.py +++ b/src/griptape_nodes/files/directory.py @@ -10,7 +10,11 @@ from pathlib import Path from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.os_events import ( + ExistingFilePolicy, + GetNextVersionIndexRequest, + GetNextVersionIndexResultSuccess, +) from griptape_nodes.retained_mode.events.project_events import ( AttemptMapAbsolutePathToProjectRequest, AttemptMapAbsolutePathToProjectResultSuccess, @@ -20,8 +24,6 @@ ) from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -_MAX_VERSION_INDEX = 9999 - class DirectoryError(Exception): """Raised when a directory operation fails.""" @@ -159,32 +161,32 @@ def create(self) -> Directory: return self._create_direct() def _create_with_versioning(self) -> Directory: - """Increment _index until a non-existent directory is found, then create it.""" + """Use GetNextVersionIndexRequest to find an available version slot, then create it.""" macro_path: MacroPath = self._dir_path # type: ignore[assignment] - for index in range(1, _MAX_VERSION_INDEX + 1): - variables = {**macro_path.variables, "_index": index} - resolve_result = GriptapeNodes.handle_request( - GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=variables) - ) - - if not isinstance(resolve_result, GetPathForMacroResultSuccess): - msg = ( - f"Attempted to create versioned directory. Failed to resolve macro: {resolve_result.result_details}" - ) - raise DirectoryError(msg) - - absolute_path = resolve_result.absolute_path - if not absolute_path.exists(): - try: - absolute_path.mkdir(parents=self._create_parents, exist_ok=False) - except FileExistsError: - continue - locked_macro = MacroPath(macro_path.parsed_macro, variables) - return _map_to_macro_directory(absolute_path, locked_macro) - - msg = f"Attempted to create versioned directory. Failed because no available path found after {_MAX_VERSION_INDEX} attempts." - raise DirectoryError(msg) + index_result = GriptapeNodes.handle_request(GetNextVersionIndexRequest(macro_path=macro_path)) + if not isinstance(index_result, GetNextVersionIndexResultSuccess): + msg = f"Attempted to create versioned directory. Failed to find available version index: {index_result.result_details}" + raise DirectoryError(msg) + + index = index_result.index if index_result.index is not None else 1 + variables = {**macro_path.variables, "_index": index} + resolve_result = GriptapeNodes.handle_request( + GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=variables) + ) + if not isinstance(resolve_result, GetPathForMacroResultSuccess): + msg = f"Attempted to create versioned directory. Failed to resolve macro: {resolve_result.result_details}" + raise DirectoryError(msg) + + absolute_path = resolve_result.absolute_path + try: + absolute_path.mkdir(parents=self._create_parents, exist_ok=False) + except FileExistsError as e: + msg = f"Attempted to create versioned directory. Failed because directory '{absolute_path}' already exists." + raise DirectoryError(msg) from e + + locked_macro = MacroPath(macro_path.parsed_macro, variables) + return _map_to_macro_directory(absolute_path, locked_macro) def _create_direct(self) -> Directory: """Create the directory without versioning.""" diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index 495e4a4dce..134df731fc 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -22,6 +22,8 @@ from griptape_nodes.files.project_file import ProjectFileDestination from griptape_nodes.retained_mode.events.os_events import ( ExistingFilePolicy, + GetNextVersionIndexRequest, + GetNextVersionIndexResultSuccess, ScanSequencesRequest, ScanSequencesResultSuccess, ) @@ -37,7 +39,6 @@ _ENTRY_VAR_NAME = "entry" _ENTRY_MACRO_PATTERN = re.compile(r"\{entry(?::(\d+))?\}") -_MAX_VERSION_INDEX = 9999 class FileSequenceError(Exception): @@ -272,11 +273,10 @@ def build_versioned_sequence_destination( existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE, create_parents: bool = True, ) -> FileSequenceDestination: - """Find the first available version index and return a locked FileSequenceDestination. + """Find the next available version index and return a locked FileSequenceDestination. - Increments ``_index`` in the entry macro starting at 1 until the corresponding - parent directory does not exist. Returns a ``FileSequenceDestination`` with - that index locked into the variables dict. + Delegates index discovery to GetNextVersionIndexRequest (a single glob pass), + then locks the returned index into the entry macro variables. Args: entry_macro: MacroPath template with ``{entry}`` and ``{_index}`` variables. @@ -287,33 +287,27 @@ def build_versioned_sequence_destination( FileSequenceDestination with a locked _index version. Raises: - FileSequenceError: If no available version is found within the limit. + FileSequenceError: If the engine cannot determine the next available version index. """ - for index in range(1, _MAX_VERSION_INDEX + 1): - probe_variables = {**entry_macro.variables, "_index": index, _ENTRY_VAR_NAME: 0} - resolve_result = GriptapeNodes.handle_request( - GetPathForMacroRequest(parsed_macro=entry_macro.parsed_macro, variables=probe_variables) + dir_template = str(PurePosixPath(entry_macro.parsed_macro.template).parent) + dir_variables = {k: v for k, v in entry_macro.variables.items() if k != _ENTRY_VAR_NAME} + dir_macro = MacroPath(ParsedMacro(dir_template), dir_variables) + + index_result = GriptapeNodes.handle_request(GetNextVersionIndexRequest(macro_path=dir_macro)) + if not isinstance(index_result, GetNextVersionIndexResultSuccess): + msg = ( + f"Attempted to find available sequence version. Failed to find version index: {index_result.result_details}" ) - if not isinstance(resolve_result, GetPathForMacroResultSuccess): - msg = f"Attempted to find available sequence version. Failed to resolve macro: {resolve_result.result_details}" - raise FileSequenceError(msg) - - parent_dir = resolve_result.absolute_path.parent - if not parent_dir.exists(): - try: - parent_dir.mkdir(parents=create_parents, exist_ok=False) - except FileExistsError: - continue - locked_vars = {**entry_macro.variables, "_index": index} - locked_macro = MacroPath(entry_macro.parsed_macro, locked_vars) - return FileSequenceDestination( - locked_macro, - existing_file_policy=existing_file_policy, - create_parents=create_parents, - ) - - msg = f"Attempted to find available sequence version. Failed because no path found after {_MAX_VERSION_INDEX} attempts." - raise FileSequenceError(msg) + raise FileSequenceError(msg) + + index = index_result.index if index_result.index is not None else 1 + locked_vars = {**entry_macro.variables, "_index": index} + locked_macro = MacroPath(entry_macro.parsed_macro, locked_vars) + return FileSequenceDestination( + locked_macro, + existing_file_policy=existing_file_policy, + create_parents=create_parents, + ) def hash_pattern_to_entry_macro(pattern: str) -> str: diff --git a/tests/unit/files/test_directory.py b/tests/unit/files/test_directory.py index f63a69dc75..f05a7343a0 100644 --- a/tests/unit/files/test_directory.py +++ b/tests/unit/files/test_directory.py @@ -7,7 +7,12 @@ from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro from griptape_nodes.files.directory import Directory, DirectoryDestination, DirectoryError -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.os_events import ( + ExistingFilePolicy, + FileIOFailureReason, + GetNextVersionIndexResultFailure, + GetNextVersionIndexResultSuccess, +) from griptape_nodes.retained_mode.events.project_events import ( AttemptMapAbsolutePathToProjectResultSuccess, GetPathForMacroResultFailure, @@ -206,6 +211,7 @@ class TestDirectoryDestinationCreateVersioning: def test_versioning_first_available_used(self, tmp_path: Path) -> None: missing_dir = tmp_path / "renders_v001" + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) resolve_result = GetPathForMacroResultSuccess( result_details="OK", resolved_path=Path("renders_v001"), @@ -216,25 +222,39 @@ def test_versioning_first_available_used(self, tmp_path: Path) -> None: macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, side_effect=[resolve_result, map_result]): + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, map_result]): directory = dest.create() assert missing_dir.is_dir() assert directory.location == "{outputs}/renders_v{_index:03}" - def test_versioning_increments_index_when_first_taken(self, tmp_path: Path) -> None: - existing_dir = tmp_path / "renders_v001" - existing_dir.mkdir() - missing_dir = tmp_path / "renders_v002" + def test_versioning_uses_index_from_engine(self, tmp_path: Path) -> None: + missing_dir = tmp_path / "renders_v003" - resolve_result_1 = GetPathForMacroResultSuccess( + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=3) + resolve_result = GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_v001"), - absolute_path=existing_dir, + resolved_path=Path("renders_v003"), + absolute_path=missing_dir, ) - resolve_result_2 = GetPathForMacroResultSuccess( + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, map_result]): + directory = dest.create() + + assert missing_dir.is_dir() + assert directory.location == "{outputs}/renders_v{_index:03}" + + def test_versioning_none_index_treated_as_one(self, tmp_path: Path) -> None: + missing_dir = tmp_path / "renders_v001" + + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=None) + resolve_result = GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_v002"), + resolved_path=Path("renders_v001"), absolute_path=missing_dir, ) map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) @@ -242,14 +262,26 @@ def test_versioning_increments_index_when_first_taken(self, tmp_path: Path) -> N macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, side_effect=[resolve_result_1, resolve_result_2, map_result]): + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, map_result]): directory = dest.create() assert missing_dir.is_dir() assert directory.location == "{outputs}/renders_v{_index:03}" - def test_versioning_handle_request_failure_raises_directory_error(self) -> None: - failure = GetPathForMacroResultFailure( + def test_versioning_index_request_failure_raises_directory_error(self) -> None: + index_failure = GetNextVersionIndexResultFailure( + result_details="Failed to determine next index", + failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES, + ) + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, return_value=index_failure), pytest.raises(DirectoryError): + dest.create() + + def test_versioning_macro_resolve_failure_raises_directory_error(self) -> None: + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_failure = GetPathForMacroResultFailure( result_details="Macro resolution failed", failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, missing_variables={"outputs"}, @@ -257,22 +289,25 @@ def test_versioning_handle_request_failure_raises_directory_error(self) -> None: macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(DirectoryError): + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_failure]), pytest.raises(DirectoryError): dest.create() - def test_versioning_all_indexes_exhausted_raises(self, tmp_path: Path) -> None: - # tmp_path always exists, so every probe returns an already-existing directory. + def test_versioning_file_exists_error_raises_directory_error(self, tmp_path: Path) -> None: + existing_dir = tmp_path / "renders_v001" + existing_dir.mkdir() + + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) resolve_result = GetPathForMacroResultSuccess( result_details="OK", resolved_path=Path("renders_v001"), - absolute_path=tmp_path, + absolute_path=existing_dir, ) + macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) with ( - patch(HANDLE_REQUEST_PATH, return_value=resolve_result), - patch("griptape_nodes.files.directory._MAX_VERSION_INDEX", 3), + patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result]), pytest.raises(DirectoryError), ): dest.create() diff --git a/tests/unit/files/test_file_sequence.py b/tests/unit/files/test_file_sequence.py index 3684a71ca5..fbe3b0e6ee 100644 --- a/tests/unit/files/test_file_sequence.py +++ b/tests/unit/files/test_file_sequence.py @@ -17,6 +17,10 @@ ) from griptape_nodes.retained_mode.events.os_events import ( ExistingFilePolicy, + FileIOFailureReason, + GetNextVersionIndexRequest, + GetNextVersionIndexResultFailure, + GetNextVersionIndexResultSuccess, ScanSequencesRequest, ScanSequencesResultFailure, ScanSequencesResultSuccess, @@ -422,98 +426,71 @@ def test_probe_macro_includes_entry_zero(self, tmp_path: Path) -> None: class TestBuildVersionedSequenceDestination: """Tests for build_versioned_sequence_destination.""" - def test_first_version_used_when_parent_missing(self, tmp_path: Path) -> None: - missing_parent = tmp_path / "renders_v001" - entry_path = missing_parent / "frame_0000.exr" - - resolve_result = GetPathForMacroResultSuccess( - result_details="OK", - resolved_path=Path("renders_v001/frame_0000.exr"), - absolute_path=entry_path, - ) + def test_first_version_used_when_engine_returns_index_one(self) -> None: + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) - with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + with patch(HANDLE_REQUEST_PATH, return_value=index_result): dest = build_versioned_sequence_destination(macro) assert dest._entry_macro.variables["_index"] == 1 - def test_second_version_used_when_first_parent_exists(self, tmp_path: Path) -> None: - existing_parent = tmp_path / "renders_v001" - existing_parent.mkdir() - missing_parent = tmp_path / "renders_v002" - - resolve_result_1 = GetPathForMacroResultSuccess( - result_details="OK", - resolved_path=Path("renders_v001/frame_0000.exr"), - absolute_path=existing_parent / "frame_0000.exr", - ) - resolve_result_2 = GetPathForMacroResultSuccess( - result_details="OK", - resolved_path=Path("renders_v002/frame_0000.exr"), - absolute_path=missing_parent / "frame_0000.exr", - ) + def test_uses_index_returned_by_engine(self) -> None: + expected_index = 3 + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=expected_index) macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) - expected_index = 2 - with patch(HANDLE_REQUEST_PATH, side_effect=[resolve_result_1, resolve_result_2]): + with patch(HANDLE_REQUEST_PATH, return_value=index_result): dest = build_versioned_sequence_destination(macro) assert dest._entry_macro.variables["_index"] == expected_index - def test_raises_when_macro_resolution_fails(self) -> None: - failure = GetPathForMacroResultFailure( - result_details="Missing variables: outputs", - failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, - missing_variables={"outputs"}, - ) + def test_none_index_treated_as_one(self) -> None: + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=None) macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) - with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(FileSequenceError): - build_versioned_sequence_destination(macro) + with patch(HANDLE_REQUEST_PATH, return_value=index_result): + dest = build_versioned_sequence_destination(macro) - def test_raises_when_all_versions_exhausted(self, tmp_path: Path) -> None: - existing_parent = tmp_path - resolve_result = GetPathForMacroResultSuccess( - result_details="OK", - resolved_path=Path("frame_0000.exr"), - absolute_path=existing_parent / "frame_0000.exr", + assert dest._entry_macro.variables["_index"] == 1 + + def test_raises_when_index_request_fails(self) -> None: + failure = GetNextVersionIndexResultFailure( + result_details="Failed to determine next index", + failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES, ) macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) - with ( - patch(HANDLE_REQUEST_PATH, return_value=resolve_result), - patch("griptape_nodes.files.file_sequence._MAX_VERSION_INDEX", 3), - pytest.raises(FileSequenceError), - ): + with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(FileSequenceError): build_versioned_sequence_destination(macro) - def test_locks_index_into_returned_destination_variables(self, tmp_path: Path) -> None: - missing_parent = tmp_path / "seq_v001" - resolve_result = GetPathForMacroResultSuccess( - result_details="OK", - resolved_path=Path("seq_v001/frame_0000.exr"), - absolute_path=missing_parent / "frame_0000.exr", - ) + def test_locks_index_into_returned_destination_variables(self) -> None: + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {"extra": "value"}) - with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + with patch(HANDLE_REQUEST_PATH, return_value=index_result): dest = build_versioned_sequence_destination(macro) assert "_index" in dest._entry_macro.variables assert "extra" in dest._entry_macro.variables assert "entry" not in dest._entry_macro.variables - def test_existing_file_policy_forwarded(self, tmp_path: Path) -> None: - missing_parent = tmp_path / "seq_v001" - resolve_result = GetPathForMacroResultSuccess( - result_details="OK", - resolved_path=Path("seq_v001/frame_0000.exr"), - absolute_path=missing_parent / "frame_0000.exr", - ) + def test_existing_file_policy_forwarded(self) -> None: + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {}) - with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + with patch(HANDLE_REQUEST_PATH, return_value=index_result): dest = build_versioned_sequence_destination(macro, existing_file_policy=ExistingFilePolicy.FAIL) assert dest._existing_file_policy == ExistingFilePolicy.FAIL + + def test_passes_directory_macro_without_entry_variable(self) -> None: + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) + macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {"extra": "val"}) + + with patch(HANDLE_REQUEST_PATH, return_value=index_result) as mock_handle: + build_versioned_sequence_destination(macro) + + request = mock_handle.call_args[0][0] + assert isinstance(request, GetNextVersionIndexRequest) + assert "entry" not in request.macro_path.variables From ab57d2e35f5f93833c9bba14b9fbb993e3e5f5a1 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Tue, 9 Jun 2026 15:13:55 +0100 Subject: [PATCH 16/22] feat: add MakeDirectoryRequest --- .../retained_mode/events/os_events.py | 50 ++++++++++++++++ .../retained_mode/managers/os_manager.py | 57 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/griptape_nodes/retained_mode/events/os_events.py b/src/griptape_nodes/retained_mode/events/os_events.py index 775f1835a4..4425285351 100644 --- a/src/griptape_nodes/retained_mode/events/os_events.py +++ b/src/griptape_nodes/retained_mode/events/os_events.py @@ -916,3 +916,53 @@ class GetNextVersionIndexResultFailure(WorkflowNotAlteredMixin, ResultPayloadFai """ failure_reason: FileIOFailureReason + + +@dataclass +@PayloadRegistry.register +class MakeDirectoryRequest(RequestPayload): + """Create a directory, optionally including intermediate parent directories. + + Use when: Creating output directories, setting up workspace folder structures, + ensuring a directory exists before writing files into it. Prefer this over + CreateFileRequest when the goal is purely directory creation. + + Args: + path: Absolute path to the directory to create + create_parents: If True, create intermediate directories as needed (default: True) + exist_ok: If True, succeed silently when the directory already exists (default: True). + Set to False to get a POLICY_NO_OVERWRITE failure if the directory exists. + + Results: MakeDirectoryResultSuccess | MakeDirectoryResultFailure + """ + + path: str + create_parents: bool = True + exist_ok: bool = True + + +@dataclass +@PayloadRegistry.register +class MakeDirectoryResultSuccess(WorkflowNotAlteredMixin, ResultPayloadSuccess): + """Directory created (or already existed) successfully. + + Attributes: + created_path: Absolute path of the directory + already_existed: True when the directory already existed and exist_ok=True + """ + + created_path: str + already_existed: bool = False + + +@dataclass +@PayloadRegistry.register +class MakeDirectoryResultFailure(WorkflowNotAlteredMixin, ResultPayloadFailure): + """Directory creation failed. + + Attributes: + failure_reason: Classification of why the creation failed + result_details: Human-readable error message (inherited from ResultPayloadFailure) + """ + + failure_reason: FileIOFailureReason diff --git a/src/griptape_nodes/retained_mode/managers/os_manager.py b/src/griptape_nodes/retained_mode/managers/os_manager.py index 06c8a36b09..ac9a78aacc 100644 --- a/src/griptape_nodes/retained_mode/managers/os_manager.py +++ b/src/griptape_nodes/retained_mode/managers/os_manager.py @@ -77,6 +77,9 @@ ListDirectoryRequest, ListDirectoryResultFailure, ListDirectoryResultSuccess, + MakeDirectoryRequest, + MakeDirectoryResultFailure, + MakeDirectoryResultSuccess, OpenAssociatedFileRequest, OpenAssociatedFileResultFailure, OpenAssociatedFileResultSuccess, @@ -343,6 +346,10 @@ def __init__(self, event_manager: EventManager | None = None): request_type=GetNextVersionIndexRequest, callback=self.on_get_next_version_index_request ) + event_manager.assign_manager_to_request_type( + request_type=MakeDirectoryRequest, callback=self.on_make_directory_request + ) + # Store event_manager for direct access during resource registration self._event_manager = event_manager @@ -2542,6 +2549,56 @@ def _cleanup_old_files(directory_path: Path, target_size_gb: float) -> bool: return removed_count > 0 + def on_make_directory_request(self, request: MakeDirectoryRequest) -> ResultPayload: # noqa: PLR0911 + """Handle a request to create a directory.""" + sanitized = sanitize_path_string(request.path) + try: + dir_path = self._resolve_file_path(sanitized, workspace_only=False) + except (ValueError, RuntimeError) as e: + msg = f"Attempted to create directory '{sanitized}'. Failed due to invalid path: {e}" + return MakeDirectoryResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg) + + if dir_path.is_file(): + msg = f"Attempted to create directory '{dir_path}'. Failed because a file already exists at that path." + return MakeDirectoryResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg) + + if dir_path.is_dir() and not request.exist_ok: + msg = f"Attempted to create directory '{dir_path}'. Failed because directory already exists." + return MakeDirectoryResultFailure( + failure_reason=FileIOFailureReason.POLICY_NO_OVERWRITE, result_details=msg + ) + + if dir_path.is_dir(): + return MakeDirectoryResultSuccess( + created_path=str(dir_path), + already_existed=True, + result_details=f"Directory already exists at {dir_path}", + ) + + normalized = normalize_path_for_platform(dir_path) + try: + Path(normalized).mkdir(parents=request.create_parents, exist_ok=request.exist_ok) + except FileNotFoundError as e: + msg = f"Attempted to create directory '{dir_path}'. Failed because parent directory does not exist and create_parents is False: {e}" + return MakeDirectoryResultFailure( + failure_reason=FileIOFailureReason.POLICY_NO_CREATE_PARENT_DIRS, result_details=msg + ) + except PermissionError as e: + msg = f"Attempted to create directory '{dir_path}'. Failed due to permission denied: {e}" + return MakeDirectoryResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg) + except OSError as e: + if "No space left" in str(e) or "Disk full" in str(e): + msg = f"Attempted to create directory '{dir_path}'. Failed due to disk full: {e}" + return MakeDirectoryResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg) + msg = f"Attempted to create directory '{dir_path}'. Failed due to I/O error: {e}" + return MakeDirectoryResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg) + + return MakeDirectoryResultSuccess( + created_path=str(dir_path), + already_existed=False, + result_details=f"Directory created successfully at {dir_path}", + ) + def on_create_file_request(self, request: CreateFileRequest) -> ResultPayload: # noqa: PLR0911, PLR0912, C901 """Handle a request to create a file or directory.""" # Get the full path From ef43367d2f2440563bc10806273e27d9df7a90d9 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 11 Jun 2026 14:45:50 +0100 Subject: [PATCH 17/22] fix: versioning logic for DirectoryDestination --- src/griptape_nodes/files/directory.py | 78 ++++++++++----- tests/unit/files/test_directory.py | 133 ++++++++++++++++++++++---- 2 files changed, 166 insertions(+), 45 deletions(-) diff --git a/src/griptape_nodes/files/directory.py b/src/griptape_nodes/files/directory.py index 3ab3cc3519..8acf5590ce 100644 --- a/src/griptape_nodes/files/directory.py +++ b/src/griptape_nodes/files/directory.py @@ -1,19 +1,19 @@ """Directory - a macro-path handle for project directories. Supports I/O-free path inspection and deferred write via DirectoryDestination. -Versioned directories (v001, v002 …) are provisioned by build_versioned_directory_destination, -which probes the filesystem via the retained mode API to find the first unused index. """ from __future__ import annotations -from pathlib import Path +import pathlib from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro from griptape_nodes.retained_mode.events.os_events import ( ExistingFilePolicy, GetNextVersionIndexRequest, GetNextVersionIndexResultSuccess, + MakeDirectoryRequest, + MakeDirectoryResultSuccess, ) from griptape_nodes.retained_mode.events.project_events import ( AttemptMapAbsolutePathToProjectRequest, @@ -63,7 +63,7 @@ def __init__(self, dir_path: str | MacroPath) -> None: else: self._dir_path = dir_path - def resolve(self) -> Path: + def resolve(self) -> pathlib.Path: """Resolve and return the absolute path for this directory. Returns: @@ -72,7 +72,7 @@ def resolve(self) -> Path: Raises: DirectoryError: If macro resolution fails (e.g. no project loaded). """ - return Path(_resolve_dir_path(self._dir_path)) + return pathlib.Path(_resolve_dir_path(self._dir_path)) @property def location(self) -> str: @@ -88,7 +88,7 @@ def location(self) -> str: @property def name(self) -> str: """Return the directory name (last path component).""" - return Path(self.location).name + return pathlib.Path(self.location).name class DirectoryDestination: @@ -125,7 +125,7 @@ def __init__( self._existing_dir_policy = existing_dir_policy self._create_parents = create_parents - def resolve(self) -> Path: + def resolve(self) -> pathlib.Path: """Resolve and return the absolute path for this destination. Returns: @@ -134,7 +134,7 @@ def resolve(self) -> Path: Raises: DirectoryError: If macro resolution fails. """ - return Path(_resolve_dir_path(self._dir_path)) + return pathlib.Path(_resolve_dir_path(self._dir_path)) @property def location(self) -> str: @@ -156,21 +156,47 @@ def create(self) -> Directory: Raises: DirectoryError: If the directory cannot be created. """ - if self._existing_dir_policy == ExistingFilePolicy.CREATE_NEW and isinstance(self._dir_path, MacroPath): - return self._create_with_versioning() - return self._create_direct() + match self._existing_dir_policy: + case ExistingFilePolicy.CREATE_NEW: + return self._create_with_versioning() + case ExistingFilePolicy.OVERWRITE: + return self._create_direct() + case ExistingFilePolicy.FAIL: + resolved = pathlib.Path(_resolve_dir_path(self._dir_path)) + if resolved.exists(): + msg = f"Attempted to create directory. Failed because directory already exists: {resolved}" + raise DirectoryError(msg) + return self._create_direct() def _create_with_versioning(self) -> Directory: - """Use GetNextVersionIndexRequest to find an available version slot, then create it.""" - macro_path: MacroPath = self._dir_path # type: ignore[assignment] + """Use GetNextVersionIndexRequest to find an available version slot, then create it. + If the path is a MacroPath with an ``_index`` variable, we can use it directly, if it's a string, we just add index in the end. + """ + if isinstance(self._dir_path, MacroPath): + macro_path = self._dir_path + else: + try: + parsed = ParsedMacro(self._dir_path) + has_variables = bool(parsed.get_variables()) + except MacroSyntaxError as exc: + msg = f"Attempted to create versioned directory. Failed because path is not a valid macro: {self._dir_path}" + raise DirectoryError(msg) from exc + + if has_variables: + macro_path = MacroPath(parsed, {}) + else: + macro_path = MacroPath(ParsedMacro(self._dir_path + "_{_index}"), {}) + + # Get the next available version index for this macro path. + # The macro is expected to contain an {_index} variable, which is used to find the next available version. index_result = GriptapeNodes.handle_request(GetNextVersionIndexRequest(macro_path=macro_path)) if not isinstance(index_result, GetNextVersionIndexResultSuccess): msg = f"Attempted to create versioned directory. Failed to find available version index: {index_result.result_details}" raise DirectoryError(msg) index = index_result.index if index_result.index is not None else 1 - variables = {**macro_path.variables, "_index": index} + variables = macro_path.variables | {"_index": index} resolve_result = GriptapeNodes.handle_request( GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=variables) ) @@ -179,24 +205,26 @@ def _create_with_versioning(self) -> Directory: raise DirectoryError(msg) absolute_path = resolve_result.absolute_path - try: - absolute_path.mkdir(parents=self._create_parents, exist_ok=False) - except FileExistsError as e: - msg = f"Attempted to create versioned directory. Failed because directory '{absolute_path}' already exists." - raise DirectoryError(msg) from e + mkdir_result = GriptapeNodes.handle_request( + MakeDirectoryRequest(path=str(absolute_path), create_parents=self._create_parents, exist_ok=False) + ) + if not isinstance(mkdir_result, MakeDirectoryResultSuccess): + msg = f"Attempted to create versioned directory. Failed to create '{absolute_path}': {mkdir_result.result_details}" + raise DirectoryError(msg) locked_macro = MacroPath(macro_path.parsed_macro, variables) return _map_to_macro_directory(absolute_path, locked_macro) def _create_direct(self) -> Directory: """Create the directory without versioning.""" - resolved = Path(_resolve_dir_path(self._dir_path)) - - if resolved.exists() and self._existing_dir_policy == ExistingFilePolicy.FAIL: - msg = f"Attempted to create directory. Failed because directory already exists: {resolved}" + resolved = pathlib.Path(_resolve_dir_path(self._dir_path)) + mkdir_result = GriptapeNodes.handle_request( + MakeDirectoryRequest(path=str(resolved), create_parents=self._create_parents, exist_ok=True) + ) + if not isinstance(mkdir_result, MakeDirectoryResultSuccess): + msg = f"Attempted to create directory. Failed to create '{resolved}': {mkdir_result.result_details}" raise DirectoryError(msg) - resolved.mkdir(parents=self._create_parents, exist_ok=True) return _map_to_macro_directory(resolved, self._dir_path) @@ -226,7 +254,7 @@ def _resolve_dir_path(dir_path: str | MacroPath) -> str: return str(resolve_result.absolute_path) -def _map_to_macro_directory(absolute_path: Path, fallback_path: str | MacroPath) -> Directory: +def _map_to_macro_directory(absolute_path: pathlib.Path, fallback_path: str | MacroPath) -> Directory: """Attempt to map the created directory path to a portable macro form. Returns a Directory holding the macro template when the path is inside diff --git a/tests/unit/files/test_directory.py b/tests/unit/files/test_directory.py index f05a7343a0..d1e5ff3f53 100644 --- a/tests/unit/files/test_directory.py +++ b/tests/unit/files/test_directory.py @@ -12,6 +12,8 @@ FileIOFailureReason, GetNextVersionIndexResultFailure, GetNextVersionIndexResultSuccess, + MakeDirectoryResultFailure, + MakeDirectoryResultSuccess, ) from griptape_nodes.retained_mode.events.project_events import ( AttemptMapAbsolutePathToProjectResultSuccess, @@ -156,23 +158,24 @@ def test_stores_create_parents_false(self) -> None: class TestDirectoryDestinationCreateDirect: - """Tests for DirectoryDestination.create() in non-versioning (direct) mode.""" + """Tests for DirectoryDestination.create() in non-versioning (direct/overwrite) mode.""" def test_create_plain_string_creates_directory(self, tmp_path: Path) -> None: dir_path = str(tmp_path / "renders") + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, return_value=map_result): + with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() - assert (tmp_path / "renders").is_dir() assert directory.resolve() == tmp_path / "renders" def test_create_overwrite_existing_dir_succeeds(self, tmp_path: Path) -> None: existing = tmp_path / "renders" existing.mkdir() + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(existing), already_existed=True) map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) dest = DirectoryDestination(str(existing), existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, return_value=map_result): + with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() assert existing.is_dir() assert directory.resolve() == existing @@ -187,28 +190,40 @@ def test_create_fail_policy_on_existing_raises(self, tmp_path: Path) -> None: def test_create_returns_directory_with_absolute_location(self, tmp_path: Path) -> None: dir_path = str(tmp_path / "output") + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, return_value=map_result): + with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() assert Path(directory.location).is_absolute() def test_create_returns_directory_with_mapped_macro_when_inside_project(self, tmp_path: Path) -> None: dir_path = str(tmp_path / "renders") + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) map_result = AttemptMapAbsolutePathToProjectResultSuccess( result_details="OK", mapped_path="{outputs}/renders", ) dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, return_value=map_result): + with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() assert directory.location == "{outputs}/renders" + def test_create_mkdir_failure_raises_directory_error(self, tmp_path: Path) -> None: + dir_path = str(tmp_path / "renders") + mkdir_failure = MakeDirectoryResultFailure( + result_details="Permission denied", + failure_reason=FileIOFailureReason.PERMISSION_DENIED, + ) + dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) + with patch(HANDLE_REQUEST_PATH, return_value=mkdir_failure), pytest.raises(DirectoryError): + dest.create() + class TestDirectoryDestinationCreateVersioning: - """Tests for DirectoryDestination.create() in versioning (CREATE_NEW + MacroPath) mode.""" + """Tests for DirectoryDestination.create() in versioning (CREATE_NEW) mode.""" - def test_versioning_first_available_used(self, tmp_path: Path) -> None: + def test_versioning_macro_path_first_available_used(self, tmp_path: Path) -> None: missing_dir = tmp_path / "renders_v001" index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) @@ -217,18 +232,18 @@ def test_versioning_first_available_used(self, tmp_path: Path) -> None: resolved_path=Path("renders_v001"), absolute_path=missing_dir, ) + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, map_result]): + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() - assert missing_dir.is_dir() assert directory.location == "{outputs}/renders_v{_index:03}" - def test_versioning_uses_index_from_engine(self, tmp_path: Path) -> None: + def test_versioning_macro_path_uses_index_from_engine(self, tmp_path: Path) -> None: missing_dir = tmp_path / "renders_v003" index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=3) @@ -237,15 +252,15 @@ def test_versioning_uses_index_from_engine(self, tmp_path: Path) -> None: resolved_path=Path("renders_v003"), absolute_path=missing_dir, ) + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, map_result]): + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() - assert missing_dir.is_dir() assert directory.location == "{outputs}/renders_v{_index:03}" def test_versioning_none_index_treated_as_one(self, tmp_path: Path) -> None: @@ -257,15 +272,15 @@ def test_versioning_none_index_treated_as_one(self, tmp_path: Path) -> None: resolved_path=Path("renders_v001"), absolute_path=missing_dir, ) + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, map_result]): + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() - assert missing_dir.is_dir() assert directory.location == "{outputs}/renders_v{_index:03}" def test_versioning_index_request_failure_raises_directory_error(self) -> None: @@ -292,22 +307,100 @@ def test_versioning_macro_resolve_failure_raises_directory_error(self) -> None: with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_failure]), pytest.raises(DirectoryError): dest.create() - def test_versioning_file_exists_error_raises_directory_error(self, tmp_path: Path) -> None: - existing_dir = tmp_path / "renders_v001" - existing_dir.mkdir() + def test_versioning_mkdir_failure_raises_directory_error(self, tmp_path: Path) -> None: + missing_dir = tmp_path / "renders_v001" index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) resolve_result = GetPathForMacroResultSuccess( result_details="OK", resolved_path=Path("renders_v001"), - absolute_path=existing_dir, + absolute_path=missing_dir, + ) + mkdir_failure = MakeDirectoryResultFailure( + result_details="Directory already exists", + failure_reason=FileIOFailureReason.POLICY_NO_OVERWRITE, ) macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) with ( - patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result]), + patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_failure]), pytest.raises(DirectoryError), ): dest.create() + + def test_create_new_plain_string_appends_index(self, tmp_path: Path) -> None: + """Regression: CREATE_NEW with a plain string must use versioning, not silently reuse the directory.""" + dir_path = str(tmp_path / "renders") + versioned_dir = tmp_path / "renders_1" + + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_1"), + absolute_path=versioned_dir, + ) + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + + dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + directory = dest.create() + + assert directory.resolve() == versioned_dir + + def test_create_new_plain_string_increments_for_next_run(self, tmp_path: Path) -> None: + """Regression: second CREATE_NEW call on the same plain-string base uses index=2, not index=1.""" + dir_path = str(tmp_path / "renders") + versioned_dir = tmp_path / "renders_2" + + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=2) + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_2"), + absolute_path=versioned_dir, + ) + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + + dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + directory = dest.create() + + assert directory.resolve() == versioned_dir + + def test_create_new_plain_string_index_request_failure_raises(self) -> None: + index_failure = GetNextVersionIndexResultFailure( + result_details="Failed to determine next index", + failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES, + ) + dest = DirectoryDestination("/some/path/renders", existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, return_value=index_failure), pytest.raises(DirectoryError): + dest.create() + + def test_create_new_macro_template_string_uses_versioning(self, tmp_path: Path) -> None: + """A macro template string passed directly to DirectoryDestination uses versioning. + + A string with variables but no {_index} is treated as a MacroPath for versioning. + """ + versioned_dir = tmp_path / "renders_v001" + + index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_result = GetPathForMacroResultSuccess( + result_details="OK", + resolved_path=Path("renders_v001"), + absolute_path=versioned_dir, + ) + mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) + map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + + dest = DirectoryDestination("{outputs}/renders_v{_index:03}", existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + + with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + directory = dest.create() + + assert directory.location == "{outputs}/renders_v{_index:03}" From 19a78a0324edbebf68db92bd6ab85f2a9e23e823 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 11 Jun 2026 15:06:56 +0100 Subject: [PATCH 18/22] refactor: deduplicate macro resolution and situation-lookup patterns Extract three shared helpers to eliminate copy-pasted boilerplate introduced alongside Directory and FileSequence: - `_resolve_macro_path` / `_aresolve_macro_path` (file.py): shared dispatch for GetPathForMacroRequest; callers supply an exc_factory to produce their domain-specific exception (FileLoadError or DirectoryError). Replaces the duplicated 10-line resolve blocks in `_resolve_file_path`, `_aresolve_file_path`, and `_resolve_dir_path`. - `ResolvedSituation` + `resolve_situation()` (project_file.py): encapsulates the GetSituationRequest lookup, policy mapping, and fallback logic that was copy-pasted across `from_situation()`, `_build_directory_destination_from_situation()`, and `_build_sequence_destination_from_situation()`. - `_attempt_map_to_project()` (project_file.py): wraps AttemptMapAbsolutePathToProjectRequest, used by both `_map_to_macro_file` and `_map_to_macro_directory`. --- .../project_directory_parameter.py | 36 ++------ .../project_file_sequence_parameter.py | 36 ++------ src/griptape_nodes/files/directory.py | 22 ++--- src/griptape_nodes/files/file.py | 82 ++++++++++------- src/griptape_nodes/files/file_sequence.py | 2 +- src/griptape_nodes/files/project_file.py | 92 ++++++++++++++----- .../test_project_directory_parameter.py | 4 +- .../test_project_file_sequence_parameter.py | 4 +- 8 files changed, 143 insertions(+), 135 deletions(-) diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index c3ed58026a..53304908f5 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -4,25 +4,15 @@ DirectoryDestination. Falls back to a sensible default when no situation is configured. """ -import logging - from griptape_nodes.common.macro_parser import ParsedMacro from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter from griptape_nodes.files.directory import DirectoryDestination -from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy -from griptape_nodes.retained_mode.events.project_events import ( - GetSituationRequest, - GetSituationResultSuccess, - MacroPath, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes +from griptape_nodes.files.project_file import resolve_situation +from griptape_nodes.retained_mode.events.project_events import MacroPath from griptape_nodes.traits.file_system_picker import FileSystemPicker -logger = logging.getLogger("griptape_nodes") - _FALLBACK_DIRECTORY_MACRO = "{outputs}/{node_name?:_}{dir_name}_v{_index:03}" @@ -137,28 +127,14 @@ def _build_directory_destination_from_situation( Returns: DirectoryDestination with a MacroPath and baked-in creation policy. """ - situation_result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation)) - - if isinstance(situation_result, GetSituationResultSuccess): - situation_obj = situation_result.situation - macro_template = situation_obj.macro - on_collision = situation_obj.policy.on_collision - existing_dir_policy = SITUATION_TO_FILE_POLICY.get(on_collision, ExistingFilePolicy.CREATE_NEW) - create_parents = situation_obj.policy.create_dirs - else: - logger.error("Failed to load situation '%s', using fallback directory macro template", situation) - macro_template = _FALLBACK_DIRECTORY_MACRO - existing_dir_policy = ExistingFilePolicy.CREATE_NEW - create_parents = True - + resolved = resolve_situation(situation, _FALLBACK_DIRECTORY_MACRO) variables: dict[str, str | int] = { "dir_name": dirname, **extra_vars, } - - macro_path = MacroPath(ParsedMacro(macro_template), variables) + macro_path = MacroPath(ParsedMacro(resolved.macro_template), variables) return DirectoryDestination( macro_path, - existing_dir_policy=existing_dir_policy, - create_parents=create_parents, + existing_dir_policy=resolved.existing_file_policy, + create_parents=resolved.create_parents, ) diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 814afb86c0..73d39d1bd6 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -5,8 +5,6 @@ directory layout; falls back to a sensible default when no situation is configured. """ -import logging - from griptape_nodes.common.macro_parser import ParsedMacro from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode @@ -16,16 +14,9 @@ build_versioned_sequence_destination, ) from griptape_nodes.files.path_utils import FilenameParts -from griptape_nodes.files.project_file import SITUATION_TO_FILE_POLICY +from griptape_nodes.files.project_file import resolve_situation from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy -from griptape_nodes.retained_mode.events.project_events import ( - GetSituationRequest, - GetSituationResultSuccess, - MacroPath, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes - -logger = logging.getLogger("griptape_nodes") +from griptape_nodes.retained_mode.events.project_events import MacroPath _FALLBACK_SEQUENCE_MACRO = ( "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{entry:04}.{file_extension}" @@ -145,31 +136,16 @@ def _build_sequence_destination_from_situation( Returns: FileSequenceDestination with a locked version index. """ - situation_result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation)) - - if isinstance(situation_result, GetSituationResultSuccess): - situation_obj = situation_result.situation - macro_template = situation_obj.macro - on_collision = situation_obj.policy.on_collision - existing_file_policy = SITUATION_TO_FILE_POLICY.get(on_collision, ExistingFilePolicy.OVERWRITE) - create_parents = situation_obj.policy.create_dirs - else: - logger.error("Failed to load situation '%s', using fallback sequence macro template", situation) - macro_template = _FALLBACK_SEQUENCE_MACRO - existing_file_policy = ExistingFilePolicy.OVERWRITE - create_parents = True - + resolved = resolve_situation(situation, _FALLBACK_SEQUENCE_MACRO, ExistingFilePolicy.OVERWRITE) parts = FilenameParts.from_filename(filename) - variables: dict[str, str | int] = { "file_name_base": parts.stem, "file_extension": parts.extension, **extra_vars, } - - macro_path = MacroPath(ParsedMacro(macro_template), variables) + macro_path = MacroPath(ParsedMacro(resolved.macro_template), variables) return build_versioned_sequence_destination( macro_path, - existing_file_policy=existing_file_policy, - create_parents=create_parents, + existing_file_policy=resolved.existing_file_policy, + create_parents=resolved.create_parents, ) diff --git a/src/griptape_nodes/files/directory.py b/src/griptape_nodes/files/directory.py index 8acf5590ce..31fa974a40 100644 --- a/src/griptape_nodes/files/directory.py +++ b/src/griptape_nodes/files/directory.py @@ -8,6 +8,8 @@ import pathlib from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro +from griptape_nodes.files.file import _resolve_macro_path +from griptape_nodes.files.project_file import _attempt_map_to_project from griptape_nodes.retained_mode.events.os_events import ( ExistingFilePolicy, GetNextVersionIndexRequest, @@ -16,8 +18,6 @@ MakeDirectoryResultSuccess, ) from griptape_nodes.retained_mode.events.project_events import ( - AttemptMapAbsolutePathToProjectRequest, - AttemptMapAbsolutePathToProjectResultSuccess, GetPathForMacroRequest, GetPathForMacroResultSuccess, MacroPath, @@ -242,17 +242,11 @@ def _resolve_dir_path(dir_path: str | MacroPath) -> str: """ if isinstance(dir_path, str): return dir_path - - resolve_result = GriptapeNodes.handle_request( - GetPathForMacroRequest(parsed_macro=dir_path.parsed_macro, variables=dir_path.variables) + return _resolve_macro_path( + dir_path, + lambda r: DirectoryError(f"Attempted to resolve directory path. Failed: {r.result_details}"), ) - if not isinstance(resolve_result, GetPathForMacroResultSuccess): - msg = f"Attempted to resolve directory path. Failed: {resolve_result.result_details}" - raise DirectoryError(msg) - - return str(resolve_result.absolute_path) - def _map_to_macro_directory(absolute_path: pathlib.Path, fallback_path: str | MacroPath) -> Directory: """Attempt to map the created directory path to a portable macro form. @@ -261,9 +255,9 @@ def _map_to_macro_directory(absolute_path: pathlib.Path, fallback_path: str | Ma a project directory, so callers can store a portable reference. Falls back to the locked MacroPath or absolute path string if mapping fails. """ - map_result = GriptapeNodes.handle_request(AttemptMapAbsolutePathToProjectRequest(absolute_path=absolute_path)) - if isinstance(map_result, AttemptMapAbsolutePathToProjectResultSuccess) and map_result.mapped_path is not None: - return Directory(map_result.mapped_path) + mapped = _attempt_map_to_project(absolute_path) + if mapped is not None: + return Directory(mapped) if isinstance(fallback_path, MacroPath): return Directory(fallback_path) return Directory(str(absolute_path)) diff --git a/src/griptape_nodes/files/file.py b/src/griptape_nodes/files/file.py index 0a2bfe33da..fd9c62bdab 100644 --- a/src/griptape_nodes/files/file.py +++ b/src/griptape_nodes/files/file.py @@ -5,7 +5,10 @@ import base64 import logging from pathlib import Path -from typing import NamedTuple, Protocol, cast, runtime_checkable +from typing import TYPE_CHECKING, NamedTuple, Protocol, cast, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Callable from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro from griptape_nodes.retained_mode.events.os_events import ( @@ -91,6 +94,49 @@ class FileContent(NamedTuple): } +def _make_file_load_error(result: GetPathForMacroResultFailure) -> FileLoadError: + return FileLoadError( + failure_reason=_PATH_FAILURE_TO_FILE_IO[result.failure_reason], + result_details=str(result.result_details), + missing_variables=result.missing_variables, + conflicting_variables=result.conflicting_variables, + ) + + +def _resolve_macro_path( + macro_path: MacroPath, + exc_factory: Callable[[GetPathForMacroResultFailure], Exception], +) -> str: + """Dispatch GetPathForMacroRequest and return the resolved absolute path string. + + Args: + macro_path: The MacroPath to resolve. + exc_factory: Called with the failure result to produce the exception to raise. + + Returns: + Resolved absolute path string. + """ + result = GriptapeNodes.handle_request( + GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=macro_path.variables) + ) + if isinstance(result, GetPathForMacroResultFailure): + raise exc_factory(result) + return str(result.absolute_path) # type: ignore[union-attr] + + +async def _aresolve_macro_path( + macro_path: MacroPath, + exc_factory: Callable[[GetPathForMacroResultFailure], Exception], +) -> str: + """Async version of _resolve_macro_path.""" + result = await GriptapeNodes.ahandle_request( + GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=macro_path.variables) + ) + if isinstance(result, GetPathForMacroResultFailure): + raise exc_factory(result) + return str(result.absolute_path) # type: ignore[union-attr] + + def _resolve_file_path(file_path: str | MacroPath) -> str: """Resolve a file path, handling MacroPath resolution if needed. @@ -105,28 +151,12 @@ def _resolve_file_path(file_path: str | MacroPath) -> str: """ if isinstance(file_path, str): return file_path - - # It's a MacroPath - resolve it via project-aware resolution - resolve_result = GriptapeNodes.handle_request( - GetPathForMacroRequest(parsed_macro=file_path.parsed_macro, variables=file_path.variables) - ) - - if isinstance(resolve_result, GetPathForMacroResultFailure): - raise FileLoadError( - failure_reason=_PATH_FAILURE_TO_FILE_IO[resolve_result.failure_reason], - result_details=str(resolve_result.result_details), - missing_variables=resolve_result.missing_variables, - conflicting_variables=resolve_result.conflicting_variables, - ) - - return str(resolve_result.absolute_path) # type: ignore[union-attr] + return _resolve_macro_path(file_path, _make_file_load_error) async def _aresolve_file_path(file_path: str | MacroPath) -> str: """Async version of _resolve_file_path. - Resolve a file path, handling MacroPath resolution if needed. - Args: file_path: A plain path string or a MacroPath. @@ -138,21 +168,7 @@ async def _aresolve_file_path(file_path: str | MacroPath) -> str: """ if isinstance(file_path, str): return file_path - - # It's a MacroPath - resolve it via project-aware resolution - resolve_result = await GriptapeNodes.ahandle_request( - GetPathForMacroRequest(parsed_macro=file_path.parsed_macro, variables=file_path.variables) - ) - - if isinstance(resolve_result, GetPathForMacroResultFailure): - raise FileLoadError( - failure_reason=_PATH_FAILURE_TO_FILE_IO[resolve_result.failure_reason], - result_details=str(resolve_result.result_details), - missing_variables=resolve_result.missing_variables, - conflicting_variables=resolve_result.conflicting_variables, - ) - - return str(resolve_result.absolute_path) # type: ignore[union-attr] + return await _aresolve_macro_path(file_path, _make_file_load_error) # Pairs of suffixes that should be treated as equivalent when comparing a diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index 134df731fc..9cf7da147c 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -52,7 +52,7 @@ def __init__(self, result_details: str) -> None: class FileSequence: """A collection of files identified by an entry-number pattern. - Internally stores a MacroPath template with a ``{entry:04}`` variable slot. + Internally stores a MacroPath template with a ``{entry:XX}`` variable slot. Exposes the industry-standard ``####`` notation at property boundaries for DCC software interop. diff --git a/src/griptape_nodes/files/project_file.py b/src/griptape_nodes/files/project_file.py index 9b5bf77012..ab5fe8e3e5 100644 --- a/src/griptape_nodes/files/project_file.py +++ b/src/griptape_nodes/files/project_file.py @@ -2,9 +2,10 @@ import logging from pathlib import Path +from typing import NamedTuple from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.common.project_templates.situation import SituationFilePolicy +from griptape_nodes.common.project_templates.situation import SituationFilePolicy, SituationTemplate from griptape_nodes.files.file import File, FileDestination from griptape_nodes.files.path_utils import FilenameParts from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy @@ -35,6 +36,67 @@ } +class ResolvedSituation(NamedTuple): + """Result of looking up a project situation by name. + + Attributes: + macro_template: The macro template string for the situation. + existing_file_policy: Mapped file collision policy. + create_parents: Whether to create intermediate directories. + situation_obj: Raw situation template, or None when the lookup failed and + fallback values are in use. + """ + + macro_template: str + existing_file_policy: ExistingFilePolicy + create_parents: bool + situation_obj: SituationTemplate | None + + +def resolve_situation( + situation_name: str, + fallback_macro: str, + default_policy: ExistingFilePolicy = ExistingFilePolicy.CREATE_NEW, +) -> ResolvedSituation: + """Look up a situation by name and return its resolved configuration. + + Falls back to fallback_macro and default_policy when the situation cannot be loaded. + + Args: + situation_name: Situation name to look up in the current project. + fallback_macro: Macro template to use when the situation cannot be found. + default_policy: ExistingFilePolicy to use in the fallback case. + + Returns: + ResolvedSituation with macro_template, existing_file_policy, create_parents, + and situation_obj (None when falling back). + """ + result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation_name)) + if isinstance(result, GetSituationResultSuccess): + situation_obj = result.situation + return ResolvedSituation( + macro_template=situation_obj.macro, + existing_file_policy=SITUATION_TO_FILE_POLICY.get(situation_obj.policy.on_collision, default_policy), + create_parents=situation_obj.policy.create_dirs, + situation_obj=situation_obj, + ) + logger.error("Failed to load situation '%s', using fallback macro template", situation_name) + return ResolvedSituation( + macro_template=fallback_macro, + existing_file_policy=default_policy, + create_parents=True, + situation_obj=None, + ) + + +def _attempt_map_to_project(absolute_path: Path) -> str | None: + """Fire AttemptMapAbsolutePathToProjectRequest; return the mapped macro path string or None.""" + map_result = GriptapeNodes.handle_request(AttemptMapAbsolutePathToProjectRequest(absolute_path=absolute_path)) + if isinstance(map_result, AttemptMapAbsolutePathToProjectResultSuccess) and map_result.mapped_path is not None: + return map_result.mapped_path + return None + + class ProjectFileDestination(FileDestination): """A FileDestination that maps written absolute paths back to project macro form. @@ -71,11 +133,9 @@ def _map_to_macro_file(self, result_file: File) -> File: portable reference via ``file.as_macro()``. Falls back to the original File (absolute path) if mapping is not possible. """ - map_result = GriptapeNodes.handle_request( - AttemptMapAbsolutePathToProjectRequest(absolute_path=Path(result_file.resolve())) - ) - if isinstance(map_result, AttemptMapAbsolutePathToProjectResultSuccess) and map_result.mapped_path is not None: - return File(map_result.mapped_path) + mapped = _attempt_map_to_project(Path(result_file.resolve())) + if mapped is not None: + return File(mapped) return result_file @classmethod @@ -95,20 +155,10 @@ def from_situation( situation: Situation name to look up in the current project. **extra_vars: Additional macro variables (e.g., node_name="MyNode", _index=1). """ - result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation)) - - if isinstance(result, GetSituationResultSuccess): - situation_obj = result.situation - macro_template = situation_obj.macro - on_collision = situation_obj.policy.on_collision - existing_file_policy = SITUATION_TO_FILE_POLICY.get(on_collision, ExistingFilePolicy.CREATE_NEW) - create_dirs = situation_obj.policy.create_dirs - else: - logger.error("Failed to load situation '%s', using fallback macro template", situation) - situation_obj = None - macro_template = FALLBACK_MACRO_TEMPLATE - existing_file_policy = ExistingFilePolicy.CREATE_NEW - create_dirs = True + resolved = resolve_situation(situation, FALLBACK_MACRO_TEMPLATE) + situation_obj = resolved.situation_obj + existing_file_policy = resolved.existing_file_policy + create_dirs = resolved.create_parents parts = FilenameParts.from_filename(filename) variables: dict[str, str | int] = { @@ -131,7 +181,7 @@ def from_situation( # caller-supplied variables here. The sidecar records the raw inputs; # anyone re-resolving the path against the current project gets the # same derived values the write used. - macro_path = MacroPath(ParsedMacro(macro_template), variables) + macro_path = MacroPath(ParsedMacro(resolved.macro_template), variables) file_metadata = ( SidecarContent( diff --git a/tests/unit/exe_types/param_components/test_project_directory_parameter.py b/tests/unit/exe_types/param_components/test_project_directory_parameter.py index 46214ca873..0b1c00e409 100644 --- a/tests/unit/exe_types/param_components/test_project_directory_parameter.py +++ b/tests/unit/exe_types/param_components/test_project_directory_parameter.py @@ -19,9 +19,7 @@ MacroPath, ) -HANDLE_REQUEST_PATH = ( - "griptape_nodes.exe_types.param_components.project_directory_parameter.GriptapeNodes.handle_request" -) +HANDLE_REQUEST_PATH = "griptape_nodes.files.project_file.GriptapeNodes.handle_request" _POLICY_MAP = { "CREATE_NEW": SituationFilePolicy.CREATE_NEW, diff --git a/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py index 360d2f1ff5..b2a7248504 100644 --- a/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py +++ b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py @@ -18,9 +18,7 @@ GetSituationResultSuccess, ) -HANDLE_REQUEST_PATH = ( - "griptape_nodes.exe_types.param_components.project_file_sequence_parameter.GriptapeNodes.handle_request" -) +HANDLE_REQUEST_PATH = "griptape_nodes.files.project_file.GriptapeNodes.handle_request" BUILD_VERSIONED_PATH = ( "griptape_nodes.exe_types.param_components.project_file_sequence_parameter.build_versioned_sequence_destination" ) From d9fb8499d4af56e4ba51e207f02e1b7e2e14422b Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Thu, 11 Jun 2026 15:50:19 +0100 Subject: [PATCH 19/22] refactor: docstring correctness for sequences --- .../param_components/project_file_sequence_parameter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 73d39d1bd6..3e5607dd5a 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -126,7 +126,7 @@ def _build_sequence_destination_from_situation( """Build a FileSequenceDestination from a project situation template. Parses the filename (or #### pattern) into parts, looks up the situation, - and builds a versioned destination by finding the first available ``_index``. + and builds a versioned destination by updating all available ``_index`` variables. Args: filename: Filename or #### pattern (e.g., ``"render.exr"`` or ``"render_####.exr"``). @@ -134,7 +134,7 @@ def _build_sequence_destination_from_situation( **extra_vars: Additional macro variables. Returns: - FileSequenceDestination with a locked version index. + FileSequenceDestination with a locked version index but unresolved element token. """ resolved = resolve_situation(situation, _FALLBACK_SEQUENCE_MACRO, ExistingFilePolicy.OVERWRITE) parts = FilenameParts.from_filename(filename) From 3e1026a6c6ee02b58c685651001a182ef2ec0f71 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Fri, 12 Jun 2026 12:32:00 +0100 Subject: [PATCH 20/22] refactor: situation handling for file sequence entries --- .../default_project_template.py | 4 +- .../project_templates/situation_resolver.py | 71 +++++++++++++++++++ .../project_directory_parameter.py | 2 +- .../project_file_sequence_parameter.py | 4 +- src/griptape_nodes/files/project_file.py | 67 +---------------- .../test_project_file_sequence_parameter.py | 16 ++--- 6 files changed, 85 insertions(+), 79 deletions(-) create mode 100644 src/griptape_nodes/common/project_templates/situation_resolver.py diff --git a/src/griptape_nodes/common/project_templates/default_project_template.py b/src/griptape_nodes/common/project_templates/default_project_template.py index 5df62b19fe..fdcc3ba3e2 100644 --- a/src/griptape_nodes/common/project_templates/default_project_template.py +++ b/src/griptape_nodes/common/project_templates/default_project_template.py @@ -151,8 +151,8 @@ ), fallback=None, ), - "save_file_sequence_entry": SituationTemplate( - name="save_file_sequence_entry", + "save_file_sequence": SituationTemplate( + name="save_file_sequence", description="Node writes a file sequence; version in directory and entry filenames", macro="{outputs}/{sub_dirs?:/}{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{entry:04}.{file_extension}", policy=SituationPolicy( diff --git a/src/griptape_nodes/common/project_templates/situation_resolver.py b/src/griptape_nodes/common/project_templates/situation_resolver.py new file mode 100644 index 0000000000..994de7206c --- /dev/null +++ b/src/griptape_nodes/common/project_templates/situation_resolver.py @@ -0,0 +1,71 @@ +"""Resolve a project situation name into file-write configuration.""" + +import logging +from typing import NamedTuple + +from griptape_nodes.common.project_templates.situation import SituationFilePolicy, SituationTemplate +from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy +from griptape_nodes.retained_mode.events.project_events import GetSituationRequest, GetSituationResultSuccess +from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes + +logger = logging.getLogger("griptape_nodes") + +SITUATION_TO_FILE_POLICY: dict[str, ExistingFilePolicy] = { + SituationFilePolicy.CREATE_NEW: ExistingFilePolicy.CREATE_NEW, + SituationFilePolicy.OVERWRITE: ExistingFilePolicy.OVERWRITE, + SituationFilePolicy.FAIL: ExistingFilePolicy.FAIL, + SituationFilePolicy.PROMPT: ExistingFilePolicy.CREATE_NEW, # PROMPT has no direct mapping; fall back to CREATE_NEW +} + + +class ResolvedSituation(NamedTuple): + """Result of looking up a project situation by name. + + Attributes: + macro_template: The macro template string for the situation. + existing_file_policy: Mapped file collision policy. + create_parents: Whether to create intermediate directories. + situation_obj: Raw situation template, or None when the lookup failed and + fallback values are in use. + """ + + macro_template: str + existing_file_policy: ExistingFilePolicy + create_parents: bool + situation_obj: SituationTemplate | None + + +def resolve_situation( + situation_name: str, + fallback_macro: str, + default_policy: ExistingFilePolicy = ExistingFilePolicy.CREATE_NEW, +) -> ResolvedSituation: + """Look up a situation by name and return its resolved configuration. + + Falls back to fallback_macro and default_policy when the situation cannot be loaded. + + Args: + situation_name: Situation name to look up in the current project. + fallback_macro: Macro template to use when the situation cannot be found. + default_policy: ExistingFilePolicy to use in the fallback case. + + Returns: + ResolvedSituation with macro_template, existing_file_policy, create_parents, + and situation_obj (None when falling back). + """ + result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation_name)) + if isinstance(result, GetSituationResultSuccess): + situation_obj = result.situation + return ResolvedSituation( + macro_template=situation_obj.macro, + existing_file_policy=SITUATION_TO_FILE_POLICY.get(situation_obj.policy.on_collision, default_policy), + create_parents=situation_obj.policy.create_dirs, + situation_obj=situation_obj, + ) + logger.error("Failed to load situation '%s', using fallback macro template", situation_name) + return ResolvedSituation( + macro_template=fallback_macro, + existing_file_policy=default_policy, + create_parents=True, + situation_obj=None, + ) diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index 53304908f5..b236fd0fdc 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -5,11 +5,11 @@ """ from griptape_nodes.common.macro_parser import ParsedMacro +from griptape_nodes.common.project_templates.situation_resolver import resolve_situation from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter from griptape_nodes.files.directory import DirectoryDestination -from griptape_nodes.files.project_file import resolve_situation from griptape_nodes.retained_mode.events.project_events import MacroPath from griptape_nodes.traits.file_system_picker import FileSystemPicker diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 3e5607dd5a..2825c71ca0 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -6,6 +6,7 @@ """ from griptape_nodes.common.macro_parser import ParsedMacro +from griptape_nodes.common.project_templates.situation_resolver import resolve_situation from griptape_nodes.exe_types.core_types import ParameterMode from griptape_nodes.exe_types.node_types import BaseNode from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter @@ -14,7 +15,6 @@ build_versioned_sequence_destination, ) from griptape_nodes.files.path_utils import FilenameParts -from griptape_nodes.files.project_file import resolve_situation from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy from griptape_nodes.retained_mode.events.project_events import MacroPath @@ -50,7 +50,7 @@ class ProjectFileSequenceParameter(ProjectOutputParameter): self.set_parameter_value("output_sequence", seq.location) """ - DEFAULT_SITUATION = "save_file_sequence_entry" + DEFAULT_SITUATION = "save_file_sequence" def __init__( # noqa: PLR0913 self, diff --git a/src/griptape_nodes/files/project_file.py b/src/griptape_nodes/files/project_file.py index ab5fe8e3e5..0b0f21d49f 100644 --- a/src/griptape_nodes/files/project_file.py +++ b/src/griptape_nodes/files/project_file.py @@ -2,18 +2,14 @@ import logging from pathlib import Path -from typing import NamedTuple from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.common.project_templates.situation import SituationFilePolicy, SituationTemplate +from griptape_nodes.common.project_templates.situation_resolver import resolve_situation from griptape_nodes.files.file import File, FileDestination from griptape_nodes.files.path_utils import FilenameParts -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy from griptape_nodes.retained_mode.events.project_events import ( AttemptMapAbsolutePathToProjectRequest, AttemptMapAbsolutePathToProjectResultSuccess, - GetSituationRequest, - GetSituationResultSuccess, MacroPath, ) from griptape_nodes.retained_mode.file_metadata.sidecar_metadata import ( @@ -28,67 +24,6 @@ FALLBACK_MACRO_TEMPLATE = "{outputs}/{node_name?:_}{file_name_base}{_index?:03}.{file_extension}" -SITUATION_TO_FILE_POLICY: dict[str, ExistingFilePolicy] = { - SituationFilePolicy.CREATE_NEW: ExistingFilePolicy.CREATE_NEW, - SituationFilePolicy.OVERWRITE: ExistingFilePolicy.OVERWRITE, - SituationFilePolicy.FAIL: ExistingFilePolicy.FAIL, - SituationFilePolicy.PROMPT: ExistingFilePolicy.CREATE_NEW, # PROMPT has no direct mapping; fall back to CREATE_NEW -} - - -class ResolvedSituation(NamedTuple): - """Result of looking up a project situation by name. - - Attributes: - macro_template: The macro template string for the situation. - existing_file_policy: Mapped file collision policy. - create_parents: Whether to create intermediate directories. - situation_obj: Raw situation template, or None when the lookup failed and - fallback values are in use. - """ - - macro_template: str - existing_file_policy: ExistingFilePolicy - create_parents: bool - situation_obj: SituationTemplate | None - - -def resolve_situation( - situation_name: str, - fallback_macro: str, - default_policy: ExistingFilePolicy = ExistingFilePolicy.CREATE_NEW, -) -> ResolvedSituation: - """Look up a situation by name and return its resolved configuration. - - Falls back to fallback_macro and default_policy when the situation cannot be loaded. - - Args: - situation_name: Situation name to look up in the current project. - fallback_macro: Macro template to use when the situation cannot be found. - default_policy: ExistingFilePolicy to use in the fallback case. - - Returns: - ResolvedSituation with macro_template, existing_file_policy, create_parents, - and situation_obj (None when falling back). - """ - result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation_name)) - if isinstance(result, GetSituationResultSuccess): - situation_obj = result.situation - return ResolvedSituation( - macro_template=situation_obj.macro, - existing_file_policy=SITUATION_TO_FILE_POLICY.get(situation_obj.policy.on_collision, default_policy), - create_parents=situation_obj.policy.create_dirs, - situation_obj=situation_obj, - ) - logger.error("Failed to load situation '%s', using fallback macro template", situation_name) - return ResolvedSituation( - macro_template=fallback_macro, - existing_file_policy=default_policy, - create_parents=True, - situation_obj=None, - ) - - def _attempt_map_to_project(absolute_path: Path) -> str | None: """Fire AttemptMapAbsolutePathToProjectRequest; return the mapped macro path string or None.""" map_result = GriptapeNodes.handle_request(AttemptMapAbsolutePathToProjectRequest(absolute_path=absolute_path)) diff --git a/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py index b2a7248504..151a889030 100644 --- a/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py +++ b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py @@ -58,7 +58,7 @@ def test_uses_situation_macro(self) -> None: patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") call_args = mock_build.call_args macro_path = call_args.args[0] @@ -87,7 +87,7 @@ def test_plain_filename_parsed_into_stem_and_extension(self) -> None: patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") macro_path = mock_build.call_args.args[0] assert macro_path.variables["file_name_base"] == "frame" @@ -102,7 +102,7 @@ def test_hash_pattern_filename_converted_before_parsing(self) -> None: patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame_####.exr", "save_file_sequence_entry") + _build_sequence_destination_from_situation("frame_####.exr", "save_file_sequence") macro_path = mock_build.call_args.args[0] assert macro_path.variables["file_extension"] == "exr" @@ -116,7 +116,7 @@ def test_extra_vars_forwarded_to_macro(self) -> None: patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry", node_name="MyNode") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence", node_name="MyNode") macro_path = mock_build.call_args.args[0] assert macro_path.variables["node_name"] == "MyNode" @@ -130,7 +130,7 @@ def test_situation_overwrite_policy_forwarded(self) -> None: patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") call_kwargs = mock_build.call_args.kwargs assert call_kwargs["existing_file_policy"] == ExistingFilePolicy.OVERWRITE @@ -144,7 +144,7 @@ def test_situation_create_dirs_forwarded(self) -> None: patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") + _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") call_kwargs = mock_build.call_args.kwargs assert call_kwargs["create_parents"] is False @@ -168,7 +168,7 @@ def test_returns_file_sequence_destination(self) -> None: mock_dest = MagicMock(spec=FileSequenceDestination) with patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest): - result = _build_sequence_destination_from_situation("frame.exr", "save_file_sequence_entry") + result = _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") assert result is mock_dest @@ -183,7 +183,7 @@ def test_multiple_extra_vars_all_forwarded(self) -> None: ): _build_sequence_destination_from_situation( "render.exr", - "save_file_sequence_entry", + "save_file_sequence", node_name="Renderer", sub_dirs="pass_1", ) From 819dd5ed3fc3a53ae038be12a488141a3294c8e3 Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Fri, 12 Jun 2026 13:04:56 +0100 Subject: [PATCH 21/22] refactor: module imports vs classes imports --- .../project_templates/situation_resolver.py | 33 +- .../project_directory_parameter.py | 33 +- .../project_file_sequence_parameter.py | 38 +- .../project_output_parameter.py | 83 ++-- src/griptape_nodes/files/directory.py | 91 ++-- src/griptape_nodes/files/file_sequence.py | 118 +++-- .../test_project_directory_parameter.py | 164 +++---- .../test_project_file_sequence_parameter.py | 176 +++---- .../test_project_output_parameter.py | 86 ++-- tests/unit/files/test_directory.py | 355 +++++++------- tests/unit/files/test_file_sequence.py | 432 +++++++++--------- 11 files changed, 813 insertions(+), 796 deletions(-) diff --git a/src/griptape_nodes/common/project_templates/situation_resolver.py b/src/griptape_nodes/common/project_templates/situation_resolver.py index 994de7206c..dffecf5f39 100644 --- a/src/griptape_nodes/common/project_templates/situation_resolver.py +++ b/src/griptape_nodes/common/project_templates/situation_resolver.py @@ -1,24 +1,23 @@ """Resolve a project situation name into file-write configuration.""" import logging -from typing import NamedTuple +import typing -from griptape_nodes.common.project_templates.situation import SituationFilePolicy, SituationTemplate -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy -from griptape_nodes.retained_mode.events.project_events import GetSituationRequest, GetSituationResultSuccess -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes +from griptape_nodes.common.project_templates import situation as situation_mod +from griptape_nodes.retained_mode import griptape_nodes as griptape_nodes_mod +from griptape_nodes.retained_mode.events import os_events, project_events logger = logging.getLogger("griptape_nodes") -SITUATION_TO_FILE_POLICY: dict[str, ExistingFilePolicy] = { - SituationFilePolicy.CREATE_NEW: ExistingFilePolicy.CREATE_NEW, - SituationFilePolicy.OVERWRITE: ExistingFilePolicy.OVERWRITE, - SituationFilePolicy.FAIL: ExistingFilePolicy.FAIL, - SituationFilePolicy.PROMPT: ExistingFilePolicy.CREATE_NEW, # PROMPT has no direct mapping; fall back to CREATE_NEW +SITUATION_TO_FILE_POLICY: dict[str, os_events.ExistingFilePolicy] = { + situation_mod.SituationFilePolicy.CREATE_NEW: os_events.ExistingFilePolicy.CREATE_NEW, + situation_mod.SituationFilePolicy.OVERWRITE: os_events.ExistingFilePolicy.OVERWRITE, + situation_mod.SituationFilePolicy.FAIL: os_events.ExistingFilePolicy.FAIL, + situation_mod.SituationFilePolicy.PROMPT: os_events.ExistingFilePolicy.CREATE_NEW, # PROMPT has no direct mapping; fall back to CREATE_NEW } -class ResolvedSituation(NamedTuple): +class ResolvedSituation(typing.NamedTuple): """Result of looking up a project situation by name. Attributes: @@ -30,15 +29,15 @@ class ResolvedSituation(NamedTuple): """ macro_template: str - existing_file_policy: ExistingFilePolicy + existing_file_policy: os_events.ExistingFilePolicy create_parents: bool - situation_obj: SituationTemplate | None + situation_obj: situation_mod.SituationTemplate | None def resolve_situation( situation_name: str, fallback_macro: str, - default_policy: ExistingFilePolicy = ExistingFilePolicy.CREATE_NEW, + default_policy: os_events.ExistingFilePolicy = os_events.ExistingFilePolicy.CREATE_NEW, ) -> ResolvedSituation: """Look up a situation by name and return its resolved configuration. @@ -53,8 +52,10 @@ def resolve_situation( ResolvedSituation with macro_template, existing_file_policy, create_parents, and situation_obj (None when falling back). """ - result = GriptapeNodes.handle_request(GetSituationRequest(situation_name=situation_name)) - if isinstance(result, GetSituationResultSuccess): + result = griptape_nodes_mod.GriptapeNodes.handle_request( + project_events.GetSituationRequest(situation_name=situation_name) + ) + if isinstance(result, project_events.GetSituationResultSuccess): situation_obj = result.situation return ResolvedSituation( macro_template=situation_obj.macro, diff --git a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py index b236fd0fdc..cd78ab0258 100644 --- a/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_directory_parameter.py @@ -4,19 +4,18 @@ DirectoryDestination. Falls back to a sensible default when no situation is configured. """ -from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.common.project_templates.situation_resolver import resolve_situation -from griptape_nodes.exe_types.core_types import ParameterMode -from griptape_nodes.exe_types.node_types import BaseNode -from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter -from griptape_nodes.files.directory import DirectoryDestination -from griptape_nodes.retained_mode.events.project_events import MacroPath -from griptape_nodes.traits.file_system_picker import FileSystemPicker +from griptape_nodes.common import macro_parser +from griptape_nodes.common.project_templates import situation_resolver +from griptape_nodes.exe_types import core_types, node_types +from griptape_nodes.exe_types.param_components import project_output_parameter +from griptape_nodes.files import directory as directory_mod +from griptape_nodes.retained_mode.events import project_events +from griptape_nodes.traits import file_system_picker _FALLBACK_DIRECTORY_MACRO = "{outputs}/{node_name?:_}{dir_name}_v{_index:03}" -class ProjectDirectoryParameter(ProjectOutputParameter): +class ProjectDirectoryParameter(project_output_parameter.ProjectOutputParameter): """Parameter component for project-aware directory creation. Adds a directory name parameter to a node that, when processed, returns a @@ -41,12 +40,12 @@ class ProjectDirectoryParameter(ProjectOutputParameter): def __init__( # noqa: PLR0913 self, - node: BaseNode, + node: node_types.BaseNode, name: str, *, default_dirname: str, situation: str = DEFAULT_SITUATION, - allowed_modes: set[ParameterMode] | None = None, + allowed_modes: set[core_types.ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: super().__init__( @@ -76,14 +75,14 @@ def _parameter_output_type(self) -> str: def _make_parameter_traits(self) -> set: return { - FileSystemPicker( + file_system_picker.FileSystemPicker( allow_files=False, allow_directories=True, allow_create=True, ) } - def build_directory(self, **extra_vars: str | int) -> DirectoryDestination: + def build_directory(self, **extra_vars: str | int) -> directory_mod.DirectoryDestination: """Build a DirectoryDestination from the parameter's current value. If an upstream node exposes a ``directory_destination`` attribute, its @@ -116,7 +115,7 @@ def _build_directory_destination_from_situation( dirname: str, situation: str, **extra_vars: str | int, -) -> DirectoryDestination: +) -> directory_mod.DirectoryDestination: """Build a DirectoryDestination from a project situation template. Args: @@ -127,13 +126,13 @@ def _build_directory_destination_from_situation( Returns: DirectoryDestination with a MacroPath and baked-in creation policy. """ - resolved = resolve_situation(situation, _FALLBACK_DIRECTORY_MACRO) + resolved = situation_resolver.resolve_situation(situation, _FALLBACK_DIRECTORY_MACRO) variables: dict[str, str | int] = { "dir_name": dirname, **extra_vars, } - macro_path = MacroPath(ParsedMacro(resolved.macro_template), variables) - return DirectoryDestination( + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(resolved.macro_template), variables) + return directory_mod.DirectoryDestination( macro_path, existing_dir_policy=resolved.existing_file_policy, create_parents=resolved.create_parents, diff --git a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py index 2825c71ca0..e6c96ea3da 100644 --- a/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_file_sequence_parameter.py @@ -5,25 +5,19 @@ directory layout; falls back to a sensible default when no situation is configured. """ -from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.common.project_templates.situation_resolver import resolve_situation -from griptape_nodes.exe_types.core_types import ParameterMode -from griptape_nodes.exe_types.node_types import BaseNode -from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter -from griptape_nodes.files.file_sequence import ( - FileSequenceDestination, - build_versioned_sequence_destination, -) -from griptape_nodes.files.path_utils import FilenameParts -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy -from griptape_nodes.retained_mode.events.project_events import MacroPath +from griptape_nodes.common import macro_parser +from griptape_nodes.common.project_templates import situation_resolver +from griptape_nodes.exe_types import core_types, node_types +from griptape_nodes.exe_types.param_components import project_output_parameter +from griptape_nodes.files import file_sequence, path_utils +from griptape_nodes.retained_mode.events import os_events, project_events _FALLBACK_SEQUENCE_MACRO = ( "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_v{_index:03}_{entry:04}.{file_extension}" ) -class ProjectFileSequenceParameter(ProjectOutputParameter): +class ProjectFileSequenceParameter(project_output_parameter.ProjectOutputParameter): """Parameter component for project-aware file sequence output. Adds a filename-pattern parameter to a node that, when processed, returns a @@ -54,12 +48,12 @@ class ProjectFileSequenceParameter(ProjectOutputParameter): def __init__( # noqa: PLR0913 self, - node: BaseNode, + node: node_types.BaseNode, name: str, *, default_filename: str, situation: str = DEFAULT_SITUATION, - allowed_modes: set[ParameterMode] | None = None, + allowed_modes: set[core_types.ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: super().__init__( @@ -87,7 +81,7 @@ def _settings_source_param_name(self) -> str: def _parameter_output_type(self) -> str: return "FileSequence" - def build_sequence(self, **extra_vars: str | int) -> FileSequenceDestination: + def build_sequence(self, **extra_vars: str | int) -> file_sequence.FileSequenceDestination: """Build a FileSequenceDestination from the parameter's current value. If an upstream node exposes a ``file_sequence_destination`` attribute, its @@ -122,7 +116,7 @@ def _build_sequence_destination_from_situation( filename: str, situation: str, **extra_vars: str | int, -) -> FileSequenceDestination: +) -> file_sequence.FileSequenceDestination: """Build a FileSequenceDestination from a project situation template. Parses the filename (or #### pattern) into parts, looks up the situation, @@ -136,15 +130,17 @@ def _build_sequence_destination_from_situation( Returns: FileSequenceDestination with a locked version index but unresolved element token. """ - resolved = resolve_situation(situation, _FALLBACK_SEQUENCE_MACRO, ExistingFilePolicy.OVERWRITE) - parts = FilenameParts.from_filename(filename) + resolved = situation_resolver.resolve_situation( + situation, _FALLBACK_SEQUENCE_MACRO, os_events.ExistingFilePolicy.OVERWRITE + ) + parts = path_utils.FilenameParts.from_filename(filename) variables: dict[str, str | int] = { "file_name_base": parts.stem, "file_extension": parts.extension, **extra_vars, } - macro_path = MacroPath(ParsedMacro(resolved.macro_template), variables) - return build_versioned_sequence_destination( + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(resolved.macro_template), variables) + return file_sequence.build_versioned_sequence_destination( macro_path, existing_file_policy=resolved.existing_file_policy, create_parents=resolved.create_parents, diff --git a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py index f04143a804..73cb109629 100644 --- a/src/griptape_nodes/exe_types/param_components/project_output_parameter.py +++ b/src/griptape_nodes/exe_types/param_components/project_output_parameter.py @@ -2,26 +2,23 @@ from __future__ import annotations +import abc import logging -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +import typing -from griptape_nodes.exe_types.core_types import NodeMessageResult, Parameter, ParameterMode -from griptape_nodes.retained_mode.events.connection_events import ( - ListConnectionsForNodeRequest, - ListConnectionsForNodeResultSuccess, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes -from griptape_nodes.retained_mode.retained_mode import RetainedMode -from griptape_nodes.traits.button import Button, ButtonDetailsMessagePayload +from griptape_nodes.exe_types import core_types +from griptape_nodes.retained_mode import griptape_nodes as griptape_nodes_mod +from griptape_nodes.retained_mode import retained_mode as retained_mode_mod +from griptape_nodes.retained_mode.events import connection_events +from griptape_nodes.traits import button as button_mod -if TYPE_CHECKING: - from griptape_nodes.exe_types.node_types import BaseNode +if typing.TYPE_CHECKING: + from griptape_nodes.exe_types import node_types logger = logging.getLogger("griptape_nodes") -class ProjectOutputParameter(ABC): +class ProjectOutputParameter(abc.ABC): """Shared base for project-aware output parameter components. Handles the cog-button pattern (create + connect a settings node) and @@ -32,12 +29,12 @@ class ProjectOutputParameter(ABC): def __init__( # noqa: PLR0913 self, - node: BaseNode, + node: node_types.BaseNode, name: str, *, default_value: str, situation: str, - allowed_modes: set[ParameterMode] | None = None, + allowed_modes: set[core_types.ParameterMode] | None = None, ui_options: dict | None = None, ) -> None: """Initialise with situation context. @@ -54,28 +51,28 @@ def __init__( # noqa: PLR0913 self._name = name self._situation_name = situation self._default_value = default_value - self._allowed_modes = allowed_modes or {ParameterMode.INPUT, ParameterMode.PROPERTY} + self._allowed_modes = allowed_modes or {core_types.ParameterMode.INPUT, core_types.ParameterMode.PROPERTY} self._ui_options = ui_options # ---- Abstract pieces each subclass must supply ---- @property - @abstractmethod + @abc.abstractmethod def _settings_node_type(self) -> str: """Node type to create when the cog button is clicked (e.g. 'FileOutputSettings').""" @property - @abstractmethod + @abc.abstractmethod def _settings_value_param_name(self) -> str: """Parameter name on the settings node that holds the filename/dirname (e.g. 'filename').""" @property - @abstractmethod + @abc.abstractmethod def _settings_source_param_name(self) -> str: """Output parameter on the settings node to wire to this parameter (e.g. 'file_destination').""" @property - @abstractmethod + @abc.abstractmethod def _parameter_output_type(self) -> str: """The output_type string for the generated parameter (e.g. 'str', 'Directory').""" @@ -95,9 +92,9 @@ def add_parameter(self) -> None: tooltip = f"Output path (uses '{self._situation_name}' situation template)" traits = self._make_parameter_traits() - if ParameterMode.INPUT in self._allowed_modes: + if core_types.ParameterMode.INPUT in self._allowed_modes: traits.add( - Button( + button_mod.Button( icon="cog", size="icon", variant="secondary", @@ -106,7 +103,7 @@ def add_parameter(self) -> None: ) ) - parameter = Parameter( + parameter = core_types.Parameter( name=self._name, type="str", default_value=self._default_value, @@ -123,7 +120,7 @@ def add_parameter(self) -> None: def _reset_to_default( self, - parameter: Parameter, # noqa: ARG002 + parameter: core_types.Parameter, # noqa: ARG002 source_node_name: str, # noqa: ARG002 source_parameter_name: str, # noqa: ARG002 ) -> None: @@ -148,14 +145,18 @@ def _get_upstream_destination( Raises: ValueError: If a connected node exposes ``destination_attr`` but returns None. """ - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=self._node.name)) - if not isinstance(result, ListConnectionsForNodeResultSuccess): + result = griptape_nodes_mod.GriptapeNodes.handle_request( + connection_events.ListConnectionsForNodeRequest(node_name=self._node.name) + ) + if not isinstance(result, connection_events.ListConnectionsForNodeResultSuccess): return None for conn in result.incoming_connections: if conn.target_parameter_name != self._name: continue - source_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(conn.source_node_name) + source_node = griptape_nodes_mod.GriptapeNodes.ObjectManager().attempt_get_object_by_name( + conn.source_node_name + ) if source_node is None or not hasattr(source_node, destination_attr): continue destination = getattr(source_node, destination_attr) @@ -172,19 +173,21 @@ def _get_upstream_destination( def _on_configure_button_clicked( self, - button: Button, # noqa: ARG002 - button_details: ButtonDetailsMessagePayload, - ) -> NodeMessageResult: + button: button_mod.Button, # noqa: ARG002 + button_details: button_mod.ButtonDetailsMessagePayload, + ) -> core_types.NodeMessageResult: """Create and connect the appropriate settings node to this parameter.""" node_name = self._node.name has_incoming = False - result = GriptapeNodes.handle_request(ListConnectionsForNodeRequest(node_name=node_name)) - if isinstance(result, ListConnectionsForNodeResultSuccess): + result = griptape_nodes_mod.GriptapeNodes.handle_request( + connection_events.ListConnectionsForNodeRequest(node_name=node_name) + ) + if isinstance(result, connection_events.ListConnectionsForNodeResultSuccess): has_incoming = any(conn.target_parameter_name == self._name for conn in result.incoming_connections) if has_incoming: - return NodeMessageResult( + return core_types.NodeMessageResult( success=False, details=f"{node_name}: {self._name} parameter already has an incoming connection", response=button_details, @@ -193,7 +196,7 @@ def _on_configure_button_clicked( # TODO: https://github.com/griptape-ai/griptape-nodes/issues/4097 # Replace with a non-RM utility for creating sibling nodes relative to a given node. - create_result = RetainedMode.create_node_relative_to( + create_result = retained_mode_mod.RetainedMode.create_node_relative_to( reference_node_name=node_name, new_node_type=self._settings_node_type, offset_side="left", @@ -203,7 +206,7 @@ def _on_configure_button_clicked( ) if not isinstance(create_result, str): - return NodeMessageResult( + return core_types.NodeMessageResult( success=False, details=f"{node_name}: Failed to create {self._settings_node_type} node", response=button_details, @@ -212,7 +215,9 @@ def _on_configure_button_clicked( configure_node_name = create_result - configure_node = GriptapeNodes.ObjectManager().attempt_get_object_by_name(configure_node_name) + configure_node = griptape_nodes_mod.GriptapeNodes.ObjectManager().attempt_get_object_by_name( + configure_node_name + ) if configure_node is not None: configure_node.set_parameter_value("situation", self._situation_name) configure_node.publish_update_to_parameter("situation", self._situation_name) @@ -222,20 +227,20 @@ def _on_configure_button_clicked( configure_node.set_parameter_value(self._settings_value_param_name, current_value) configure_node.publish_update_to_parameter(self._settings_value_param_name, current_value) - connection_result = RetainedMode.connect( + connection_result = retained_mode_mod.RetainedMode.connect( source=f"{configure_node_name}.{self._settings_source_param_name}", destination=f"{node_name}.{self._name}", ) if not connection_result.succeeded(): - return NodeMessageResult( + return core_types.NodeMessageResult( success=False, details=f"{node_name}: Failed to connect {configure_node_name}.{self._settings_source_param_name} to {self._name}", response=button_details, altered_workflow_state=True, ) - return NodeMessageResult( + return core_types.NodeMessageResult( success=True, details=f"{node_name}: Created and connected {configure_node_name}", response=button_details, diff --git a/src/griptape_nodes/files/directory.py b/src/griptape_nodes/files/directory.py index 31fa974a40..8411bf7469 100644 --- a/src/griptape_nodes/files/directory.py +++ b/src/griptape_nodes/files/directory.py @@ -7,22 +7,11 @@ import pathlib -from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro -from griptape_nodes.files.file import _resolve_macro_path -from griptape_nodes.files.project_file import _attempt_map_to_project -from griptape_nodes.retained_mode.events.os_events import ( - ExistingFilePolicy, - GetNextVersionIndexRequest, - GetNextVersionIndexResultSuccess, - MakeDirectoryRequest, - MakeDirectoryResultSuccess, -) -from griptape_nodes.retained_mode.events.project_events import ( - GetPathForMacroRequest, - GetPathForMacroResultSuccess, - MacroPath, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes +from griptape_nodes.common import macro_parser +from griptape_nodes.files import file as file_mod +from griptape_nodes.files import project_file +from griptape_nodes.retained_mode import griptape_nodes as griptape_nodes_mod +from griptape_nodes.retained_mode.events import os_events, project_events class DirectoryError(Exception): @@ -44,7 +33,7 @@ class Directory: automatically wrapped in a MacroPath. """ - def __init__(self, dir_path: str | MacroPath) -> None: + def __init__(self, dir_path: str | project_events.MacroPath) -> None: """Store directory reference. No I/O is performed. Args: @@ -52,12 +41,12 @@ def __init__(self, dir_path: str | MacroPath) -> None: """ if isinstance(dir_path, str): try: - parsed = ParsedMacro(dir_path) - except MacroSyntaxError: - self._dir_path: str | MacroPath = dir_path + parsed = macro_parser.ParsedMacro(dir_path) + except macro_parser.MacroSyntaxError: + self._dir_path: str | project_events.MacroPath = dir_path else: if parsed.get_variables(): - self._dir_path = MacroPath(parsed, {}) + self._dir_path = project_events.MacroPath(parsed, {}) else: self._dir_path = dir_path else: @@ -81,7 +70,7 @@ def location(self) -> str: Returns the macro template when the directory holds a macro path, otherwise the plain path string. No I/O is performed. """ - if isinstance(self._dir_path, MacroPath): + if isinstance(self._dir_path, project_events.MacroPath): return self._dir_path.parsed_macro.template return self._dir_path @@ -106,9 +95,9 @@ class DirectoryDestination: def __init__( self, - dir_path: str | MacroPath, + dir_path: str | project_events.MacroPath, *, - existing_dir_policy: ExistingFilePolicy = ExistingFilePolicy.CREATE_NEW, + existing_dir_policy: os_events.ExistingFilePolicy = os_events.ExistingFilePolicy.CREATE_NEW, create_parents: bool = True, ) -> None: """Store directory path and creation configuration. No I/O is performed. @@ -139,7 +128,7 @@ def resolve(self) -> pathlib.Path: @property def location(self) -> str: """Return the most portable string representation of this destination's location.""" - if isinstance(self._dir_path, MacroPath): + if isinstance(self._dir_path, project_events.MacroPath): return self._dir_path.parsed_macro.template return self._dir_path @@ -157,11 +146,11 @@ def create(self) -> Directory: DirectoryError: If the directory cannot be created. """ match self._existing_dir_policy: - case ExistingFilePolicy.CREATE_NEW: + case os_events.ExistingFilePolicy.CREATE_NEW: return self._create_with_versioning() - case ExistingFilePolicy.OVERWRITE: + case os_events.ExistingFilePolicy.OVERWRITE: return self._create_direct() - case ExistingFilePolicy.FAIL: + case os_events.ExistingFilePolicy.FAIL: resolved = pathlib.Path(_resolve_dir_path(self._dir_path)) if resolved.exists(): msg = f"Attempted to create directory. Failed because directory already exists: {resolved}" @@ -173,62 +162,64 @@ def _create_with_versioning(self) -> Directory: If the path is a MacroPath with an ``_index`` variable, we can use it directly, if it's a string, we just add index in the end. """ - if isinstance(self._dir_path, MacroPath): + if isinstance(self._dir_path, project_events.MacroPath): macro_path = self._dir_path else: try: - parsed = ParsedMacro(self._dir_path) + parsed = macro_parser.ParsedMacro(self._dir_path) has_variables = bool(parsed.get_variables()) - except MacroSyntaxError as exc: + except macro_parser.MacroSyntaxError as exc: msg = f"Attempted to create versioned directory. Failed because path is not a valid macro: {self._dir_path}" raise DirectoryError(msg) from exc if has_variables: - macro_path = MacroPath(parsed, {}) + macro_path = project_events.MacroPath(parsed, {}) else: - macro_path = MacroPath(ParsedMacro(self._dir_path + "_{_index}"), {}) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(self._dir_path + "_{_index}"), {}) # Get the next available version index for this macro path. # The macro is expected to contain an {_index} variable, which is used to find the next available version. - index_result = GriptapeNodes.handle_request(GetNextVersionIndexRequest(macro_path=macro_path)) - if not isinstance(index_result, GetNextVersionIndexResultSuccess): + index_result = griptape_nodes_mod.GriptapeNodes.handle_request( + os_events.GetNextVersionIndexRequest(macro_path=macro_path) + ) + if not isinstance(index_result, os_events.GetNextVersionIndexResultSuccess): msg = f"Attempted to create versioned directory. Failed to find available version index: {index_result.result_details}" raise DirectoryError(msg) index = index_result.index if index_result.index is not None else 1 variables = macro_path.variables | {"_index": index} - resolve_result = GriptapeNodes.handle_request( - GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=variables) + resolve_result = griptape_nodes_mod.GriptapeNodes.handle_request( + project_events.GetPathForMacroRequest(parsed_macro=macro_path.parsed_macro, variables=variables) ) - if not isinstance(resolve_result, GetPathForMacroResultSuccess): + if not isinstance(resolve_result, project_events.GetPathForMacroResultSuccess): msg = f"Attempted to create versioned directory. Failed to resolve macro: {resolve_result.result_details}" raise DirectoryError(msg) absolute_path = resolve_result.absolute_path - mkdir_result = GriptapeNodes.handle_request( - MakeDirectoryRequest(path=str(absolute_path), create_parents=self._create_parents, exist_ok=False) + mkdir_result = griptape_nodes_mod.GriptapeNodes.handle_request( + os_events.MakeDirectoryRequest(path=str(absolute_path), create_parents=self._create_parents, exist_ok=False) ) - if not isinstance(mkdir_result, MakeDirectoryResultSuccess): + if not isinstance(mkdir_result, os_events.MakeDirectoryResultSuccess): msg = f"Attempted to create versioned directory. Failed to create '{absolute_path}': {mkdir_result.result_details}" raise DirectoryError(msg) - locked_macro = MacroPath(macro_path.parsed_macro, variables) + locked_macro = project_events.MacroPath(macro_path.parsed_macro, variables) return _map_to_macro_directory(absolute_path, locked_macro) def _create_direct(self) -> Directory: """Create the directory without versioning.""" resolved = pathlib.Path(_resolve_dir_path(self._dir_path)) - mkdir_result = GriptapeNodes.handle_request( - MakeDirectoryRequest(path=str(resolved), create_parents=self._create_parents, exist_ok=True) + mkdir_result = griptape_nodes_mod.GriptapeNodes.handle_request( + os_events.MakeDirectoryRequest(path=str(resolved), create_parents=self._create_parents, exist_ok=True) ) - if not isinstance(mkdir_result, MakeDirectoryResultSuccess): + if not isinstance(mkdir_result, os_events.MakeDirectoryResultSuccess): msg = f"Attempted to create directory. Failed to create '{resolved}': {mkdir_result.result_details}" raise DirectoryError(msg) return _map_to_macro_directory(resolved, self._dir_path) -def _resolve_dir_path(dir_path: str | MacroPath) -> str: +def _resolve_dir_path(dir_path: str | project_events.MacroPath) -> str: """Resolve a directory path, handling MacroPath resolution if needed. Args: @@ -242,22 +233,22 @@ def _resolve_dir_path(dir_path: str | MacroPath) -> str: """ if isinstance(dir_path, str): return dir_path - return _resolve_macro_path( + return file_mod._resolve_macro_path( dir_path, lambda r: DirectoryError(f"Attempted to resolve directory path. Failed: {r.result_details}"), ) -def _map_to_macro_directory(absolute_path: pathlib.Path, fallback_path: str | MacroPath) -> Directory: +def _map_to_macro_directory(absolute_path: pathlib.Path, fallback_path: str | project_events.MacroPath) -> Directory: """Attempt to map the created directory path to a portable macro form. Returns a Directory holding the macro template when the path is inside a project directory, so callers can store a portable reference. Falls back to the locked MacroPath or absolute path string if mapping fails. """ - mapped = _attempt_map_to_project(absolute_path) + mapped = project_file._attempt_map_to_project(absolute_path) if mapped is not None: return Directory(mapped) - if isinstance(fallback_path, MacroPath): + if isinstance(fallback_path, project_events.MacroPath): return Directory(fallback_path) return Directory(str(absolute_path)) diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index 9cf7da147c..6a99c5f438 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -8,34 +8,22 @@ from __future__ import annotations +import pathlib import re -from pathlib import PurePosixPath -from typing import TYPE_CHECKING - -from fileseq.constants import PAD_STYLE_HASH1 -from fileseq.filesequence import FileSequence as _FSeq - -from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.common.sequences import MissingItemPolicy, Sequence -from griptape_nodes.files.directory import Directory -from griptape_nodes.files.file import File, FileDestination -from griptape_nodes.files.project_file import ProjectFileDestination -from griptape_nodes.retained_mode.events.os_events import ( - ExistingFilePolicy, - GetNextVersionIndexRequest, - GetNextVersionIndexResultSuccess, - ScanSequencesRequest, - ScanSequencesResultSuccess, -) -from griptape_nodes.retained_mode.events.project_events import ( - GetPathForMacroRequest, - GetPathForMacroResultSuccess, - MacroPath, -) -from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes - -if TYPE_CHECKING: - from collections.abc import Callable +import typing + +from fileseq import constants as fileseq_constants +from fileseq import filesequence as fileseq_filesequence + +from griptape_nodes.common import macro_parser, sequences +from griptape_nodes.files import directory as directory_mod +from griptape_nodes.files import file as file_mod +from griptape_nodes.files import project_file +from griptape_nodes.retained_mode import griptape_nodes as griptape_nodes_mod +from griptape_nodes.retained_mode.events import os_events, project_events + +if typing.TYPE_CHECKING: + import collections.abc _ENTRY_VAR_NAME = "entry" _ENTRY_MACRO_PATTERN = re.compile(r"\{entry(?::(\d+))?\}") @@ -60,7 +48,7 @@ class FileSequence: ``directory`` to get the containing folder. """ - def __init__(self, entry_macro: MacroPath) -> None: + def __init__(self, entry_macro: project_events.MacroPath) -> None: """Store the entry macro. No I/O is performed. Args: @@ -96,18 +84,18 @@ def pattern(self) -> str: return entry_macro_to_hash_pattern(self.location) @property - def directory(self) -> Directory: + def directory(self) -> directory_mod.Directory: """Return the containing directory as a Directory. No I/O is performed; the directory path is derived from the macro template by stripping the filename component. The locked variables (e.g. ``_index``) are preserved so the returned Directory can be resolved. """ - dir_template = str(PurePosixPath(self.location).parent) + dir_template = str(pathlib.PurePosixPath(self.location).parent) dir_variables = {k: v for k, v in self._entry_macro.variables.items() if k != _ENTRY_VAR_NAME} - return Directory(MacroPath(ParsedMacro(dir_template), dir_variables)) + return directory_mod.Directory(project_events.MacroPath(macro_parser.ParsedMacro(dir_template), dir_variables)) - def entry(self, entry_number: int) -> File: + def entry(self, entry_number: int) -> file_mod.File: """Return a File for reading a specific entry. Args: @@ -117,15 +105,15 @@ def entry(self, entry_number: int) -> File: File that resolves to the absolute path of that entry. """ variables = {**self._entry_macro.variables, _ENTRY_VAR_NAME: entry_number} - return File(MacroPath(self._entry_macro.parsed_macro, variables)) + return file_mod.File(project_events.MacroPath(self._entry_macro.parsed_macro, variables)) def scan( self, *, - policy: MissingItemPolicy = MissingItemPolicy.SPLIT, + policy: sequences.MissingItemPolicy = sequences.MissingItemPolicy.SPLIT, start: int | None = None, end: int | None = None, - ) -> list[Sequence]: + ) -> list[sequences.Sequence]: """Scan the sequence directory and return what's on disk. Args: @@ -138,19 +126,19 @@ def scan( or contains no matching files. """ probe_vars = {**self._entry_macro.variables, _ENTRY_VAR_NAME: 0} - resolve_result = GriptapeNodes.handle_request( - GetPathForMacroRequest(parsed_macro=self._entry_macro.parsed_macro, variables=probe_vars) + resolve_result = griptape_nodes_mod.GriptapeNodes.handle_request( + project_events.GetPathForMacroRequest(parsed_macro=self._entry_macro.parsed_macro, variables=probe_vars) ) - if not isinstance(resolve_result, GetPathForMacroResultSuccess): + if not isinstance(resolve_result, project_events.GetPathForMacroResultSuccess): return [] resolved_dir = str(resolve_result.absolute_path.parent) - filename_template = PurePosixPath(self.location).name + filename_template = pathlib.PurePosixPath(self.location).name entry_match = _ENTRY_MACRO_PATTERN.search(filename_template) entry_width = int(entry_match.group(1)) if entry_match and entry_match.group(1) else 4 entry_zero_str = format(0, f"0{entry_width}d") filename_pattern = resolve_result.absolute_path.name.replace(entry_zero_str, "#" * entry_width, 1) - scan_result = GriptapeNodes.handle_request( - ScanSequencesRequest( + scan_result = griptape_nodes_mod.GriptapeNodes.handle_request( + os_events.ScanSequencesRequest( directory=resolved_dir, pattern=filename_pattern, policy=policy, @@ -158,21 +146,21 @@ def scan( end_number=end, ) ) - if not isinstance(scan_result, ScanSequencesResultSuccess): + if not isinstance(scan_result, os_events.ScanSequencesResultSuccess): return [] return scan_result.sequences -class _EntryWriteDestination(ProjectFileDestination): +class _EntryWriteDestination(project_file.ProjectFileDestination): """FileDestination subclass that fires a callback after each successful write.""" def __init__( self, - entry_path: MacroPath, + entry_path: project_events.MacroPath, *, - existing_file_policy: ExistingFilePolicy, + existing_file_policy: os_events.ExistingFilePolicy, create_parents: bool, - on_written: Callable[[File], None], + on_written: collections.abc.Callable[[file_mod.File], None], ) -> None: super().__init__( entry_path, @@ -181,22 +169,22 @@ def __init__( ) self._on_written = on_written - def write_bytes(self, content: bytes) -> File: + def write_bytes(self, content: bytes) -> file_mod.File: result = super().write_bytes(content) self._on_written(result) return result - async def awrite_bytes(self, content: bytes) -> File: + async def awrite_bytes(self, content: bytes) -> file_mod.File: result = await super().awrite_bytes(content) self._on_written(result) return result - def write_text(self, content: str, encoding: str = "utf-8") -> File: + def write_text(self, content: str, encoding: str = "utf-8") -> file_mod.File: result = super().write_text(content, encoding) self._on_written(result) return result - async def awrite_text(self, content: str, encoding: str = "utf-8") -> File: + async def awrite_text(self, content: str, encoding: str = "utf-8") -> file_mod.File: result = await super().awrite_text(content, encoding) self._on_written(result) return result @@ -214,9 +202,9 @@ class FileSequenceDestination: def __init__( self, - entry_macro: MacroPath, + entry_macro: project_events.MacroPath, *, - existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE, + existing_file_policy: os_events.ExistingFilePolicy = os_events.ExistingFilePolicy.OVERWRITE, create_parents: bool = True, ) -> None: """Store entry macro and write configuration. No I/O is performed. @@ -240,7 +228,7 @@ def file_sequence(self) -> FileSequence | None: """ return self._written_sequence - def entry(self, entry_number: int) -> FileDestination: + def entry(self, entry_number: int) -> file_mod.FileDestination: """Return a FileDestination for writing a specific entry. After the returned destination is used to write, the ``file_sequence`` @@ -253,7 +241,7 @@ def entry(self, entry_number: int) -> FileDestination: FileDestination pre-configured with the resolved entry path and policy. """ variables = {**self._entry_macro.variables, _ENTRY_VAR_NAME: entry_number} - entry_path = MacroPath(self._entry_macro.parsed_macro, variables) + entry_path = project_events.MacroPath(self._entry_macro.parsed_macro, variables) return _EntryWriteDestination( entry_path, existing_file_policy=self._existing_file_policy, @@ -261,16 +249,16 @@ def entry(self, entry_number: int) -> FileDestination: on_written=self._on_entry_written, ) - def _on_entry_written(self, written_file: File) -> None: # noqa: ARG002 + def _on_entry_written(self, written_file: file_mod.File) -> None: # noqa: ARG002 """Record that an entry was written to expose the FileSequence descriptor.""" if self._written_sequence is None: self._written_sequence = FileSequence(self._entry_macro) def build_versioned_sequence_destination( - entry_macro: MacroPath, + entry_macro: project_events.MacroPath, *, - existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE, + existing_file_policy: os_events.ExistingFilePolicy = os_events.ExistingFilePolicy.OVERWRITE, create_parents: bool = True, ) -> FileSequenceDestination: """Find the next available version index and return a locked FileSequenceDestination. @@ -289,12 +277,14 @@ def build_versioned_sequence_destination( Raises: FileSequenceError: If the engine cannot determine the next available version index. """ - dir_template = str(PurePosixPath(entry_macro.parsed_macro.template).parent) + dir_template = str(pathlib.PurePosixPath(entry_macro.parsed_macro.template).parent) dir_variables = {k: v for k, v in entry_macro.variables.items() if k != _ENTRY_VAR_NAME} - dir_macro = MacroPath(ParsedMacro(dir_template), dir_variables) + dir_macro = project_events.MacroPath(macro_parser.ParsedMacro(dir_template), dir_variables) - index_result = GriptapeNodes.handle_request(GetNextVersionIndexRequest(macro_path=dir_macro)) - if not isinstance(index_result, GetNextVersionIndexResultSuccess): + index_result = griptape_nodes_mod.GriptapeNodes.handle_request( + os_events.GetNextVersionIndexRequest(macro_path=dir_macro) + ) + if not isinstance(index_result, os_events.GetNextVersionIndexResultSuccess): msg = ( f"Attempted to find available sequence version. Failed to find version index: {index_result.result_details}" ) @@ -302,7 +292,7 @@ def build_versioned_sequence_destination( index = index_result.index if index_result.index is not None else 1 locked_vars = {**entry_macro.variables, "_index": index} - locked_macro = MacroPath(entry_macro.parsed_macro, locked_vars) + locked_macro = project_events.MacroPath(entry_macro.parsed_macro, locked_vars) return FileSequenceDestination( locked_macro, existing_file_policy=existing_file_policy, @@ -323,8 +313,8 @@ def hash_pattern_to_entry_macro(pattern: str) -> str: Macro template string with the token replaced by ``{entry:NN}``. Returns the input unchanged if no sequence token is found. """ - path = PurePosixPath(pattern) - fseq = _FSeq(path.name, pad_style=PAD_STYLE_HASH1) + path = pathlib.PurePosixPath(pattern) + fseq = fileseq_filesequence.FileSequence(path.name, pad_style=fileseq_constants.PAD_STYLE_HASH1) width = fseq.zfill() if width == 0: return pattern diff --git a/tests/unit/exe_types/param_components/test_project_directory_parameter.py b/tests/unit/exe_types/param_components/test_project_directory_parameter.py index 0b1c00e409..e445afc954 100644 --- a/tests/unit/exe_types/param_components/test_project_directory_parameter.py +++ b/tests/unit/exe_types/param_components/test_project_directory_parameter.py @@ -1,30 +1,18 @@ """Unit tests for _build_directory_destination_from_situation.""" -from unittest.mock import patch - -from griptape_nodes.common.project_templates.situation import ( - SituationFilePolicy, - SituationPolicy, - SituationTemplate, -) -from griptape_nodes.exe_types.param_components.project_directory_parameter import ( - _FALLBACK_DIRECTORY_MACRO, - _build_directory_destination_from_situation, -) -from griptape_nodes.files.directory import DirectoryDestination -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy -from griptape_nodes.retained_mode.events.project_events import ( - GetSituationResultFailure, - GetSituationResultSuccess, - MacroPath, -) +from unittest import mock + +from griptape_nodes.common.project_templates import situation +from griptape_nodes.exe_types.param_components import project_directory_parameter +from griptape_nodes.files import directory as directory_mod +from griptape_nodes.retained_mode.events import os_events, project_events HANDLE_REQUEST_PATH = "griptape_nodes.files.project_file.GriptapeNodes.handle_request" _POLICY_MAP = { - "CREATE_NEW": SituationFilePolicy.CREATE_NEW, - "OVERWRITE": SituationFilePolicy.OVERWRITE, - "FAIL": SituationFilePolicy.FAIL, + "CREATE_NEW": situation.SituationFilePolicy.CREATE_NEW, + "OVERWRITE": situation.SituationFilePolicy.OVERWRITE, + "FAIL": situation.SituationFilePolicy.FAIL, } @@ -33,11 +21,11 @@ def _make_situation( on_collision: str = "CREATE_NEW", *, create_dirs: bool = True, -) -> SituationTemplate: - return SituationTemplate( +) -> situation.SituationTemplate: + return situation.SituationTemplate( name="test_situation", macro=macro, - policy=SituationPolicy(on_collision=_POLICY_MAP[on_collision], create_dirs=create_dirs), + policy=situation.SituationPolicy(on_collision=_POLICY_MAP[on_collision], create_dirs=create_dirs), ) @@ -45,111 +33,131 @@ class TestBuildDirectoryDestinationFromSituation: """Tests for _build_directory_destination_from_situation helper.""" def test_uses_situation_macro(self) -> None: - situation = _make_situation("{outputs}/{node_name}/{dir_name}_v{_index:03}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{node_name}/{dir_name}_v{_index:03}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("renders", "save_output_directory") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "save_output_directory" + ) - assert isinstance(dest, DirectoryDestination) - assert isinstance(dest._dir_path, MacroPath) + assert isinstance(dest, directory_mod.DirectoryDestination) + assert isinstance(dest._dir_path, project_events.MacroPath) assert dest._dir_path.parsed_macro.template == "{outputs}/{node_name}/{dir_name}_v{_index:03}" def test_falls_back_to_default_macro_when_situation_not_found(self) -> None: - failure = GetSituationResultFailure(result_details="not found") + failure = project_events.GetSituationResultFailure(result_details="not found") - with patch(HANDLE_REQUEST_PATH, return_value=failure): - dest = _build_directory_destination_from_situation("renders", "missing_situation") + with mock.patch(HANDLE_REQUEST_PATH, return_value=failure): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "missing_situation" + ) - assert isinstance(dest._dir_path, MacroPath) - assert dest._dir_path.parsed_macro.template == _FALLBACK_DIRECTORY_MACRO + assert isinstance(dest._dir_path, project_events.MacroPath) + assert dest._dir_path.parsed_macro.template == project_directory_parameter._FALLBACK_DIRECTORY_MACRO def test_wires_dirname_as_macro_variable(self) -> None: - situation = _make_situation("{outputs}/{dir_name}_v{_index:03}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{dir_name}_v{_index:03}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("frames", "save_output_directory") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "frames", "save_output_directory" + ) - assert isinstance(dest._dir_path, MacroPath) + assert isinstance(dest._dir_path, project_events.MacroPath) assert dest._dir_path.variables["dir_name"] == "frames" def test_extra_vars_forwarded_to_macro(self) -> None: - situation = _make_situation("{outputs}/{node_name}/{dir_name}_v{_index:03}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{node_name}/{dir_name}_v{_index:03}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("renders", "save_output_directory", node_name="MyNode") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "save_output_directory", node_name="MyNode" + ) - assert isinstance(dest._dir_path, MacroPath) + assert isinstance(dest._dir_path, project_events.MacroPath) assert dest._dir_path.variables["node_name"] == "MyNode" def test_situation_overwrite_policy_maps_to_overwrite(self) -> None: - situation = _make_situation("{outputs}/{dir_name}", on_collision="OVERWRITE") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{dir_name}", on_collision="OVERWRITE") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("renders", "save_output_directory") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "save_output_directory" + ) - assert dest._existing_dir_policy == ExistingFilePolicy.OVERWRITE + assert dest._existing_dir_policy == os_events.ExistingFilePolicy.OVERWRITE def test_situation_create_new_policy_maps_to_create_new(self) -> None: - situation = _make_situation("{outputs}/{dir_name}", on_collision="CREATE_NEW") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{dir_name}", on_collision="CREATE_NEW") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("renders", "save_output_directory") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "save_output_directory" + ) - assert dest._existing_dir_policy == ExistingFilePolicy.CREATE_NEW + assert dest._existing_dir_policy == os_events.ExistingFilePolicy.CREATE_NEW def test_situation_fail_policy_maps_to_fail(self) -> None: - situation = _make_situation("{outputs}/{dir_name}", on_collision="FAIL") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{dir_name}", on_collision="FAIL") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("renders", "save_output_directory") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "save_output_directory" + ) - assert dest._existing_dir_policy == ExistingFilePolicy.FAIL + assert dest._existing_dir_policy == os_events.ExistingFilePolicy.FAIL def test_situation_create_dirs_false_propagated(self) -> None: - situation = _make_situation("{outputs}/{dir_name}", create_dirs=False) - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{dir_name}", create_dirs=False) + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("renders", "save_output_directory") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "save_output_directory" + ) assert dest._create_parents is False def test_fallback_uses_create_new_policy(self) -> None: - failure = GetSituationResultFailure(result_details="not found") + failure = project_events.GetSituationResultFailure(result_details="not found") - with patch(HANDLE_REQUEST_PATH, return_value=failure): - dest = _build_directory_destination_from_situation("renders", "missing_situation") + with mock.patch(HANDLE_REQUEST_PATH, return_value=failure): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "missing_situation" + ) - assert dest._existing_dir_policy == ExistingFilePolicy.CREATE_NEW + assert dest._existing_dir_policy == os_events.ExistingFilePolicy.CREATE_NEW def test_multiple_extra_vars_all_forwarded(self) -> None: - situation = _make_situation("{outputs}/{dir_name}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{dir_name}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation( + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( "renders", "save_output_directory", node_name="MyNode", sub_dirs="pass_1", ) - assert isinstance(dest._dir_path, MacroPath) + assert isinstance(dest._dir_path, project_events.MacroPath) assert dest._dir_path.variables["node_name"] == "MyNode" assert dest._dir_path.variables["sub_dirs"] == "pass_1" assert dest._dir_path.variables["dir_name"] == "renders" def test_returns_directory_destination(self) -> None: - situation = _make_situation("{outputs}/{dir_name}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") + sit = _make_situation("{outputs}/{dir_name}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") - with patch(HANDLE_REQUEST_PATH, return_value=success): - dest = _build_directory_destination_from_situation("renders", "save_output_directory") + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): + dest = project_directory_parameter._build_directory_destination_from_situation( + "renders", "save_output_directory" + ) - assert isinstance(dest, DirectoryDestination) + assert isinstance(dest, directory_mod.DirectoryDestination) diff --git a/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py index 151a889030..d2e269bf56 100644 --- a/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py +++ b/tests/unit/exe_types/param_components/test_project_file_sequence_parameter.py @@ -1,32 +1,19 @@ """Unit tests for _build_sequence_destination_from_situation.""" -from unittest.mock import MagicMock, patch - -from griptape_nodes.common.project_templates.situation import ( - SituationFilePolicy, - SituationPolicy, - SituationTemplate, -) -from griptape_nodes.exe_types.param_components.project_file_sequence_parameter import ( - _FALLBACK_SEQUENCE_MACRO, - _build_sequence_destination_from_situation, -) -from griptape_nodes.files.file_sequence import FileSequenceDestination -from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy -from griptape_nodes.retained_mode.events.project_events import ( - GetSituationResultFailure, - GetSituationResultSuccess, -) +from unittest import mock + +from griptape_nodes.common.project_templates import situation +from griptape_nodes.exe_types.param_components import project_file_sequence_parameter +from griptape_nodes.files import file_sequence +from griptape_nodes.retained_mode.events import os_events, project_events HANDLE_REQUEST_PATH = "griptape_nodes.files.project_file.GriptapeNodes.handle_request" -BUILD_VERSIONED_PATH = ( - "griptape_nodes.exe_types.param_components.project_file_sequence_parameter.build_versioned_sequence_destination" -) +BUILD_VERSIONED_PATH = "griptape_nodes.files.file_sequence.build_versioned_sequence_destination" _POLICY_MAP = { - "CREATE_NEW": SituationFilePolicy.CREATE_NEW, - "OVERWRITE": SituationFilePolicy.OVERWRITE, - "FAIL": SituationFilePolicy.FAIL, + "CREATE_NEW": situation.SituationFilePolicy.CREATE_NEW, + "OVERWRITE": situation.SituationFilePolicy.OVERWRITE, + "FAIL": situation.SituationFilePolicy.FAIL, } @@ -35,11 +22,11 @@ def _make_situation( on_collision: str = "OVERWRITE", *, create_dirs: bool = True, -) -> SituationTemplate: - return SituationTemplate( +) -> situation.SituationTemplate: + return situation.SituationTemplate( name="test_situation", macro=macro, - policy=SituationPolicy(on_collision=_POLICY_MAP[on_collision], create_dirs=create_dirs), + policy=situation.SituationPolicy(on_collision=_POLICY_MAP[on_collision], create_dirs=create_dirs), ) @@ -50,138 +37,155 @@ def test_uses_situation_macro(self) -> None: situation_macro = ( "{outputs}/{node_name?:_}{file_name_base}_v{_index:03}/{file_name_base}_{entry:04}.{file_extension}" ) - situation = _make_situation(situation_macro) - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation(situation_macro) + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") + project_file_sequence_parameter._build_sequence_destination_from_situation( + "frame.exr", "save_file_sequence" + ) call_args = mock_build.call_args macro_path = call_args.args[0] assert macro_path.parsed_macro.template == situation_macro def test_falls_back_to_default_macro_when_situation_not_found(self) -> None: - failure = GetSituationResultFailure(result_details="not found") - mock_dest = MagicMock(spec=FileSequenceDestination) + failure = project_events.GetSituationResultFailure(result_details="not found") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=failure), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=failure), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "missing_situation") + project_file_sequence_parameter._build_sequence_destination_from_situation("frame.exr", "missing_situation") call_args = mock_build.call_args macro_path = call_args.args[0] - assert macro_path.parsed_macro.template == _FALLBACK_SEQUENCE_MACRO + assert macro_path.parsed_macro.template == project_file_sequence_parameter._FALLBACK_SEQUENCE_MACRO def test_plain_filename_parsed_into_stem_and_extension(self) -> None: - situation = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") + project_file_sequence_parameter._build_sequence_destination_from_situation( + "frame.exr", "save_file_sequence" + ) macro_path = mock_build.call_args.args[0] assert macro_path.variables["file_name_base"] == "frame" assert macro_path.variables["file_extension"] == "exr" def test_hash_pattern_filename_converted_before_parsing(self) -> None: - situation = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame_####.exr", "save_file_sequence") + project_file_sequence_parameter._build_sequence_destination_from_situation( + "frame_####.exr", "save_file_sequence" + ) macro_path = mock_build.call_args.args[0] assert macro_path.variables["file_extension"] == "exr" def test_extra_vars_forwarded_to_macro(self) -> None: - situation = _make_situation("{outputs}/{node_name}/{file_name_base}_{entry:04}.{file_extension}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation("{outputs}/{node_name}/{file_name_base}_{entry:04}.{file_extension}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence", node_name="MyNode") + project_file_sequence_parameter._build_sequence_destination_from_situation( + "frame.exr", "save_file_sequence", node_name="MyNode" + ) macro_path = mock_build.call_args.args[0] assert macro_path.variables["node_name"] == "MyNode" def test_situation_overwrite_policy_forwarded(self) -> None: - situation = _make_situation("{outputs}/{entry:04}.exr", on_collision="OVERWRITE") - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation("{outputs}/{entry:04}.exr", on_collision="OVERWRITE") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") + project_file_sequence_parameter._build_sequence_destination_from_situation( + "frame.exr", "save_file_sequence" + ) call_kwargs = mock_build.call_args.kwargs - assert call_kwargs["existing_file_policy"] == ExistingFilePolicy.OVERWRITE + assert call_kwargs["existing_file_policy"] == os_events.ExistingFilePolicy.OVERWRITE def test_situation_create_dirs_forwarded(self) -> None: - situation = _make_situation("{outputs}/{entry:04}.exr", create_dirs=False) - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation("{outputs}/{entry:04}.exr", create_dirs=False) + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") + project_file_sequence_parameter._build_sequence_destination_from_situation( + "frame.exr", "save_file_sequence" + ) call_kwargs = mock_build.call_args.kwargs assert call_kwargs["create_parents"] is False def test_fallback_uses_overwrite_policy(self) -> None: - failure = GetSituationResultFailure(result_details="not found") - mock_dest = MagicMock(spec=FileSequenceDestination) + failure = project_events.GetSituationResultFailure(result_details="not found") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=failure), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=failure), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation("frame.exr", "missing_situation") + project_file_sequence_parameter._build_sequence_destination_from_situation("frame.exr", "missing_situation") call_kwargs = mock_build.call_args.kwargs - assert call_kwargs["existing_file_policy"] == ExistingFilePolicy.OVERWRITE + assert call_kwargs["existing_file_policy"] == os_events.ExistingFilePolicy.OVERWRITE def test_returns_file_sequence_destination(self) -> None: - situation = _make_situation("{outputs}/{entry:04}.exr") - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation("{outputs}/{entry:04}.exr") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) - with patch(HANDLE_REQUEST_PATH, return_value=success), patch(BUILD_VERSIONED_PATH, return_value=mock_dest): - result = _build_sequence_destination_from_situation("frame.exr", "save_file_sequence") + with ( + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest), + ): + result = project_file_sequence_parameter._build_sequence_destination_from_situation( + "frame.exr", "save_file_sequence" + ) assert result is mock_dest def test_multiple_extra_vars_all_forwarded(self) -> None: - situation = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") - success = GetSituationResultSuccess(situation=situation, result_details="ok") - mock_dest = MagicMock(spec=FileSequenceDestination) + sit = _make_situation("{outputs}/{file_name_base}_{entry:04}.{file_extension}") + success = project_events.GetSituationResultSuccess(situation=sit, result_details="ok") + mock_dest = mock.MagicMock(spec=file_sequence.FileSequenceDestination) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(BUILD_VERSIONED_PATH, return_value=mock_dest) as mock_build, ): - _build_sequence_destination_from_situation( + project_file_sequence_parameter._build_sequence_destination_from_situation( "render.exr", "save_file_sequence", node_name="Renderer", diff --git a/tests/unit/exe_types/param_components/test_project_output_parameter.py b/tests/unit/exe_types/param_components/test_project_output_parameter.py index 1ca5b2df93..c85b56128b 100644 --- a/tests/unit/exe_types/param_components/test_project_output_parameter.py +++ b/tests/unit/exe_types/param_components/test_project_output_parameter.py @@ -1,22 +1,18 @@ """Unit tests for ProjectOutputParameter._get_upstream_destination.""" -from unittest.mock import MagicMock, patch +from unittest import mock import pytest -from griptape_nodes.exe_types.core_types import ParameterMode -from griptape_nodes.exe_types.param_components.project_output_parameter import ProjectOutputParameter -from griptape_nodes.retained_mode.events.connection_events import ( - IncomingConnection, - ListConnectionsForNodeResultFailure, - ListConnectionsForNodeResultSuccess, -) +from griptape_nodes.exe_types import core_types +from griptape_nodes.exe_types.param_components import project_output_parameter +from griptape_nodes.retained_mode.events import connection_events -HANDLE_REQUEST_PATH = "griptape_nodes.exe_types.param_components.project_output_parameter.GriptapeNodes.handle_request" -OBJECT_MANAGER_PATH = "griptape_nodes.exe_types.param_components.project_output_parameter.GriptapeNodes.ObjectManager" +HANDLE_REQUEST_PATH = "griptape_nodes.retained_mode.griptape_nodes.GriptapeNodes.handle_request" +OBJECT_MANAGER_PATH = "griptape_nodes.retained_mode.griptape_nodes.GriptapeNodes.ObjectManager" -class _ConcreteParam(ProjectOutputParameter): +class _ConcreteParam(project_output_parameter.ProjectOutputParameter): """Minimal concrete subclass for testing the base class.""" @property @@ -37,13 +33,15 @@ def _parameter_output_type(self) -> str: def _make_param(param_name: str = "output") -> _ConcreteParam: - mock_node = MagicMock() + mock_node = mock.MagicMock() mock_node.name = "MyNode" return _ConcreteParam(mock_node, param_name, default_value="default.txt", situation="save_node_output") -def _make_connections_result(*connections: IncomingConnection) -> ListConnectionsForNodeResultSuccess: - return ListConnectionsForNodeResultSuccess( +def _make_connections_result( + *connections: connection_events.IncomingConnection, +) -> connection_events.ListConnectionsForNodeResultSuccess: + return connection_events.ListConnectionsForNodeResultSuccess( result_details="ok", incoming_connections=list(connections), outgoing_connections=[], @@ -54,8 +52,8 @@ def _make_connection( target_param: str, source_node: str = "UpstreamNode", source_param: str = "test_destination", -) -> IncomingConnection: - return IncomingConnection( +) -> connection_events.IncomingConnection: + return connection_events.IncomingConnection( source_node_name=source_node, source_parameter_name=source_param, target_parameter_name=target_param, @@ -67,9 +65,9 @@ class TestGetUpstreamDestination: def test_returns_none_when_list_connections_fails(self) -> None: param = _make_param() - failure = ListConnectionsForNodeResultFailure(result_details="error") + failure = connection_events.ListConnectionsForNodeResultFailure(result_details="error") - with patch(HANDLE_REQUEST_PATH, return_value=failure): + with mock.patch(HANDLE_REQUEST_PATH, return_value=failure): result = param._get_upstream_destination("test_destination", "TestDestination") assert result is None @@ -78,7 +76,7 @@ def test_returns_none_when_no_incoming_connections(self) -> None: param = _make_param() success = _make_connections_result() - with patch(HANDLE_REQUEST_PATH, return_value=success): + with mock.patch(HANDLE_REQUEST_PATH, return_value=success): result = param._get_upstream_destination("test_destination", "TestDestination") assert result is None @@ -87,11 +85,11 @@ def test_returns_none_when_connection_targets_different_parameter(self) -> None: param = _make_param("output") conn = _make_connection(target_param="other_param") success = _make_connections_result(conn) - mock_source = MagicMock(spec=[]) # no attributes + mock_source = mock.MagicMock(spec=[]) # no attributes with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(OBJECT_MANAGER_PATH) as mock_om, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(OBJECT_MANAGER_PATH) as mock_om, ): mock_om.return_value.attempt_get_object_by_name.return_value = mock_source result = param._get_upstream_destination("test_destination", "TestDestination") @@ -104,8 +102,8 @@ def test_returns_none_when_source_node_not_found(self) -> None: success = _make_connections_result(conn) with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(OBJECT_MANAGER_PATH) as mock_om, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(OBJECT_MANAGER_PATH) as mock_om, ): mock_om.return_value.attempt_get_object_by_name.return_value = None result = param._get_upstream_destination("test_destination", "TestDestination") @@ -116,11 +114,11 @@ def test_returns_none_when_source_node_lacks_attribute(self) -> None: param = _make_param() conn = _make_connection(target_param="output") success = _make_connections_result(conn) - mock_source = MagicMock(spec=[]) # no attributes at all + mock_source = mock.MagicMock(spec=[]) # no attributes at all with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(OBJECT_MANAGER_PATH) as mock_om, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(OBJECT_MANAGER_PATH) as mock_om, ): mock_om.return_value.attempt_get_object_by_name.return_value = mock_source result = param._get_upstream_destination("test_destination", "TestDestination") @@ -131,13 +129,13 @@ def test_returns_destination_when_source_has_attribute(self) -> None: param = _make_param() conn = _make_connection(target_param="output") success = _make_connections_result(conn) - expected_dest = MagicMock() - mock_source = MagicMock(spec=["test_destination"]) + expected_dest = mock.MagicMock() + mock_source = mock.MagicMock(spec=["test_destination"]) mock_source.test_destination = expected_dest with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(OBJECT_MANAGER_PATH) as mock_om, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(OBJECT_MANAGER_PATH) as mock_om, ): mock_om.return_value.attempt_get_object_by_name.return_value = mock_source result = param._get_upstream_destination("test_destination", "TestDestination") @@ -148,10 +146,10 @@ def test_raises_when_provider_attribute_returns_none(self) -> None: param = _make_param() conn = _make_connection(target_param="output") success = _make_connections_result(conn) - mock_source = MagicMock(spec=["test_destination"]) + mock_source = mock.MagicMock(spec=["test_destination"]) mock_source.test_destination = None - with patch(HANDLE_REQUEST_PATH, return_value=success), patch(OBJECT_MANAGER_PATH) as mock_om: + with mock.patch(HANDLE_REQUEST_PATH, return_value=success), mock.patch(OBJECT_MANAGER_PATH) as mock_om: mock_om.return_value.attempt_get_object_by_name.return_value = mock_source with pytest.raises(ValueError, match="UpstreamNode"): param._get_upstream_destination("test_destination", "TestDestination") @@ -163,17 +161,17 @@ def test_skips_non_provider_and_returns_provider_destination(self) -> None: conn_provider = _make_connection(target_param="output", source_node="ProviderNode") success = _make_connections_result(conn_non_provider, conn_provider) - expected_dest = MagicMock() - plain_source = MagicMock(spec=[]) # no test_destination - provider_source = MagicMock(spec=["test_destination"]) + expected_dest = mock.MagicMock() + plain_source = mock.MagicMock(spec=[]) # no test_destination + provider_source = mock.MagicMock(spec=["test_destination"]) provider_source.test_destination = expected_dest - def get_node(name: str) -> MagicMock: + def get_node(name: str) -> mock.MagicMock: return plain_source if name == "PlainNode" else provider_source with ( - patch(HANDLE_REQUEST_PATH, return_value=success), - patch(OBJECT_MANAGER_PATH) as mock_om, + mock.patch(HANDLE_REQUEST_PATH, return_value=success), + mock.patch(OBJECT_MANAGER_PATH) as mock_om, ): mock_om.return_value.attempt_get_object_by_name.side_effect = get_node result = param._get_upstream_destination("test_destination", "TestDestination") @@ -182,10 +180,12 @@ def get_node(name: str) -> MagicMock: def test_allowed_modes_default(self) -> None: param = _make_param() - assert param._allowed_modes == {ParameterMode.INPUT, ParameterMode.PROPERTY} + assert param._allowed_modes == {core_types.ParameterMode.INPUT, core_types.ParameterMode.PROPERTY} def test_custom_allowed_modes(self) -> None: - mock_node = MagicMock() + mock_node = mock.MagicMock() mock_node.name = "N" - param = _ConcreteParam(mock_node, "out", default_value="x", situation="s", allowed_modes={ParameterMode.OUTPUT}) - assert param._allowed_modes == {ParameterMode.OUTPUT} + param = _ConcreteParam( + mock_node, "out", default_value="x", situation="s", allowed_modes={core_types.ParameterMode.OUTPUT} + ) + assert param._allowed_modes == {core_types.ParameterMode.OUTPUT} diff --git a/tests/unit/files/test_directory.py b/tests/unit/files/test_directory.py index d1e5ff3f53..7c0057654f 100644 --- a/tests/unit/files/test_directory.py +++ b/tests/unit/files/test_directory.py @@ -1,120 +1,110 @@ """Unit tests for Directory and DirectoryDestination.""" -from pathlib import Path -from unittest.mock import patch +import pathlib +from unittest import mock import pytest -from griptape_nodes.common.macro_parser import MacroSyntaxError, ParsedMacro -from griptape_nodes.files.directory import Directory, DirectoryDestination, DirectoryError -from griptape_nodes.retained_mode.events.os_events import ( - ExistingFilePolicy, - FileIOFailureReason, - GetNextVersionIndexResultFailure, - GetNextVersionIndexResultSuccess, - MakeDirectoryResultFailure, - MakeDirectoryResultSuccess, -) -from griptape_nodes.retained_mode.events.project_events import ( - AttemptMapAbsolutePathToProjectResultSuccess, - GetPathForMacroResultFailure, - GetPathForMacroResultSuccess, - MacroPath, - PathResolutionFailureReason, -) - -HANDLE_REQUEST_PATH = "griptape_nodes.files.directory.GriptapeNodes.handle_request" +from griptape_nodes.common import macro_parser +from griptape_nodes.files import directory as directory_mod +from griptape_nodes.retained_mode.events import os_events, project_events + +HANDLE_REQUEST_PATH = "griptape_nodes.retained_mode.griptape_nodes.GriptapeNodes.handle_request" class TestDirectoryConstructor: """Tests that Directory constructor stores references without I/O.""" def test_stores_plain_string(self) -> None: - d = Directory("workspace/renders") + d = directory_mod.Directory("workspace/renders") assert d._dir_path == "workspace/renders" def test_does_no_io(self) -> None: - with patch(HANDLE_REQUEST_PATH) as mock_handle: - Directory("workspace/renders") + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: + directory_mod.Directory("workspace/renders") mock_handle.assert_not_called() def test_auto_wraps_macro_string_in_macro_path(self) -> None: - d = Directory("{outputs}/frames") - assert isinstance(d._dir_path, MacroPath) + d = directory_mod.Directory("{outputs}/frames") + assert isinstance(d._dir_path, project_events.MacroPath) assert d._dir_path.variables == {} def test_keeps_plain_string_without_vars_unchanged(self) -> None: - d = Directory("workspace/frames") + d = directory_mod.Directory("workspace/frames") assert d._dir_path == "workspace/frames" def test_stores_macro_path_unchanged(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {"outputs": "/resolved"}) - d = Directory(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders"), {"outputs": "/resolved"}) + d = directory_mod.Directory(macro_path) assert d._dir_path is macro_path def test_invalid_macro_syntax_stored_as_plain_string(self) -> None: - with patch("griptape_nodes.files.directory.ParsedMacro", side_effect=MacroSyntaxError("bad")): - d = Directory("{unclosed") + with mock.patch( + "griptape_nodes.files.directory.macro_parser.ParsedMacro", side_effect=macro_parser.MacroSyntaxError("bad") + ): + d = directory_mod.Directory("{unclosed") assert d._dir_path == "{unclosed" def test_macro_string_preserves_template(self) -> None: - d = Directory("{outputs}/renders_v001") - assert isinstance(d._dir_path, MacroPath) + d = directory_mod.Directory("{outputs}/renders_v001") + assert isinstance(d._dir_path, project_events.MacroPath) assert d._dir_path.parsed_macro.template == "{outputs}/renders_v001" class TestDirectoryResolve: """Tests for Directory.resolve().""" - def test_resolve_plain_string_returns_path(self, tmp_path: Path) -> None: + def test_resolve_plain_string_returns_path(self, tmp_path: pathlib.Path) -> None: dir_path = str(tmp_path / "renders") - d = Directory(dir_path) - with patch(HANDLE_REQUEST_PATH) as mock_handle: + d = directory_mod.Directory(dir_path) + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: result = d.resolve() mock_handle.assert_not_called() - assert result == Path(dir_path) + assert result == pathlib.Path(dir_path) def test_resolve_macro_path_calls_handle_request(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {"outputs": "/workspace/outputs"}) - resolve_result = GetPathForMacroResultSuccess( + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/renders"), {"outputs": "/workspace/outputs"} + ) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("outputs/renders"), - absolute_path=Path("/workspace/outputs/renders"), + resolved_path=pathlib.Path("outputs/renders"), + absolute_path=pathlib.Path("/workspace/outputs/renders"), ) - with patch(HANDLE_REQUEST_PATH, return_value=resolve_result): - result = Directory(macro_path).resolve() - assert result == Path("/workspace/outputs/renders") + with mock.patch(HANDLE_REQUEST_PATH, return_value=resolve_result): + result = directory_mod.Directory(macro_path).resolve() + assert result == pathlib.Path("/workspace/outputs/renders") def test_resolve_macro_path_failure_raises_directory_error(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {}) - failure = GetPathForMacroResultFailure( + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders"), {}) + failure = project_events.GetPathForMacroResultFailure( result_details="Missing variables: outputs", - failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, + failure_reason=project_events.PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, missing_variables={"outputs"}, ) - with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(DirectoryError): - Directory(macro_path).resolve() + with mock.patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(directory_mod.DirectoryError): + directory_mod.Directory(macro_path).resolve() class TestDirectoryLocation: """Tests for Directory.location property.""" def test_location_plain_string(self) -> None: - d = Directory("workspace/renders") + d = directory_mod.Directory("workspace/renders") assert d.location == "workspace/renders" def test_location_macro_path_returns_template(self) -> None: - d = Directory("{outputs}/renders") + d = directory_mod.Directory("{outputs}/renders") assert d.location == "{outputs}/renders" def test_location_macro_path_object_returns_template(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/renders"), {"outputs": "/resolved"}) - d = Directory(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders"), {"outputs": "/resolved"}) + d = directory_mod.Directory(macro_path) assert d.location == "{outputs}/renders" def test_location_no_io_performed(self) -> None: - with patch(HANDLE_REQUEST_PATH) as mock_handle: - d = Directory("{outputs}/renders") + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: + d = directory_mod.Directory("{outputs}/renders") _ = d.location mock_handle.assert_not_called() @@ -123,15 +113,15 @@ class TestDirectoryName: """Tests for Directory.name property.""" def test_name_plain_string(self) -> None: - d = Directory("workspace/renders") + d = directory_mod.Directory("workspace/renders") assert d.name == "renders" def test_name_macro_template(self) -> None: - d = Directory("{outputs}/renders_v001") + d = directory_mod.Directory("{outputs}/renders_v001") assert d.name == "renders_v001" def test_name_nested_path(self) -> None: - d = Directory("workspace/project/outputs/frames") + d = directory_mod.Directory("workspace/project/outputs/frames") assert d.name == "frames" @@ -139,268 +129,293 @@ class TestDirectoryDestinationConstructor: """Tests for DirectoryDestination constructor.""" def test_does_no_io(self) -> None: - with patch(HANDLE_REQUEST_PATH) as mock_handle: - DirectoryDestination("workspace/renders") + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: + directory_mod.DirectoryDestination("workspace/renders") mock_handle.assert_not_called() def test_defaults_create_new_and_create_parents(self) -> None: - dest = DirectoryDestination("workspace/renders") - assert dest._existing_dir_policy == ExistingFilePolicy.CREATE_NEW + dest = directory_mod.DirectoryDestination("workspace/renders") + assert dest._existing_dir_policy == os_events.ExistingFilePolicy.CREATE_NEW assert dest._create_parents is True def test_stores_overwrite_policy(self) -> None: - dest = DirectoryDestination("workspace/renders", existing_dir_policy=ExistingFilePolicy.OVERWRITE) - assert dest._existing_dir_policy == ExistingFilePolicy.OVERWRITE + dest = directory_mod.DirectoryDestination( + "workspace/renders", existing_dir_policy=os_events.ExistingFilePolicy.OVERWRITE + ) + assert dest._existing_dir_policy == os_events.ExistingFilePolicy.OVERWRITE def test_stores_create_parents_false(self) -> None: - dest = DirectoryDestination("workspace/renders", create_parents=False) + dest = directory_mod.DirectoryDestination("workspace/renders", create_parents=False) assert dest._create_parents is False class TestDirectoryDestinationCreateDirect: """Tests for DirectoryDestination.create() in non-versioning (direct/overwrite) mode.""" - def test_create_plain_string_creates_directory(self, tmp_path: Path) -> None: + def test_create_plain_string_creates_directory(self, tmp_path: pathlib.Path) -> None: dir_path = str(tmp_path / "renders") - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + dest = directory_mod.DirectoryDestination(dir_path, existing_dir_policy=os_events.ExistingFilePolicy.OVERWRITE) + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() assert directory.resolve() == tmp_path / "renders" - def test_create_overwrite_existing_dir_succeeds(self, tmp_path: Path) -> None: + def test_create_overwrite_existing_dir_succeeds(self, tmp_path: pathlib.Path) -> None: existing = tmp_path / "renders" existing.mkdir() - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(existing), already_existed=True) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - dest = DirectoryDestination(str(existing), existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): + mkdir_result = os_events.MakeDirectoryResultSuccess( + result_details="OK", created_path=str(existing), already_existed=True + ) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + dest = directory_mod.DirectoryDestination( + str(existing), existing_dir_policy=os_events.ExistingFilePolicy.OVERWRITE + ) + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() assert existing.is_dir() assert directory.resolve() == existing - def test_create_fail_policy_on_existing_raises(self, tmp_path: Path) -> None: + def test_create_fail_policy_on_existing_raises(self, tmp_path: pathlib.Path) -> None: existing = tmp_path / "renders" existing.mkdir() - dest = DirectoryDestination(str(existing), existing_dir_policy=ExistingFilePolicy.FAIL) - with patch(HANDLE_REQUEST_PATH) as mock_handle, pytest.raises(DirectoryError): + dest = directory_mod.DirectoryDestination(str(existing), existing_dir_policy=os_events.ExistingFilePolicy.FAIL) + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle, pytest.raises(directory_mod.DirectoryError): dest.create() mock_handle.assert_not_called() - def test_create_returns_directory_with_absolute_location(self, tmp_path: Path) -> None: + def test_create_returns_directory_with_absolute_location(self, tmp_path: pathlib.Path) -> None: dir_path = str(tmp_path / "output") - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + dest = directory_mod.DirectoryDestination(dir_path, existing_dir_policy=os_events.ExistingFilePolicy.OVERWRITE) + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() - assert Path(directory.location).is_absolute() + assert pathlib.Path(directory.location).is_absolute() - def test_create_returns_directory_with_mapped_macro_when_inside_project(self, tmp_path: Path) -> None: + def test_create_returns_directory_with_mapped_macro_when_inside_project(self, tmp_path: pathlib.Path) -> None: dir_path = str(tmp_path / "renders") - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) - map_result = AttemptMapAbsolutePathToProjectResultSuccess( + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=dir_path) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess( result_details="OK", mapped_path="{outputs}/renders", ) - dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): + dest = directory_mod.DirectoryDestination(dir_path, existing_dir_policy=os_events.ExistingFilePolicy.OVERWRITE) + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[mkdir_result, map_result]): directory = dest.create() assert directory.location == "{outputs}/renders" - def test_create_mkdir_failure_raises_directory_error(self, tmp_path: Path) -> None: + def test_create_mkdir_failure_raises_directory_error(self, tmp_path: pathlib.Path) -> None: dir_path = str(tmp_path / "renders") - mkdir_failure = MakeDirectoryResultFailure( + mkdir_failure = os_events.MakeDirectoryResultFailure( result_details="Permission denied", - failure_reason=FileIOFailureReason.PERMISSION_DENIED, + failure_reason=os_events.FileIOFailureReason.PERMISSION_DENIED, ) - dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.OVERWRITE) - with patch(HANDLE_REQUEST_PATH, return_value=mkdir_failure), pytest.raises(DirectoryError): + dest = directory_mod.DirectoryDestination(dir_path, existing_dir_policy=os_events.ExistingFilePolicy.OVERWRITE) + with mock.patch(HANDLE_REQUEST_PATH, return_value=mkdir_failure), pytest.raises(directory_mod.DirectoryError): dest.create() class TestDirectoryDestinationCreateVersioning: """Tests for DirectoryDestination.create() in versioning (CREATE_NEW) mode.""" - def test_versioning_macro_path_first_available_used(self, tmp_path: Path) -> None: + def test_versioning_macro_path_first_available_used(self, tmp_path: pathlib.Path) -> None: missing_dir = tmp_path / "renders_v001" - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - resolve_result = GetPathForMacroResultSuccess( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_v001"), + resolved_path=pathlib.Path("renders_v001"), absolute_path=missing_dir, ) - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) - dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = directory_mod.DirectoryDestination( + macro_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW + ) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() assert directory.location == "{outputs}/renders_v{_index:03}" - def test_versioning_macro_path_uses_index_from_engine(self, tmp_path: Path) -> None: + def test_versioning_macro_path_uses_index_from_engine(self, tmp_path: pathlib.Path) -> None: missing_dir = tmp_path / "renders_v003" - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=3) - resolve_result = GetPathForMacroResultSuccess( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=3) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_v003"), + resolved_path=pathlib.Path("renders_v003"), absolute_path=missing_dir, ) - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) - dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = directory_mod.DirectoryDestination( + macro_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW + ) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() assert directory.location == "{outputs}/renders_v{_index:03}" - def test_versioning_none_index_treated_as_one(self, tmp_path: Path) -> None: + def test_versioning_none_index_treated_as_one(self, tmp_path: pathlib.Path) -> None: missing_dir = tmp_path / "renders_v001" - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=None) - resolve_result = GetPathForMacroResultSuccess( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=None) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_v001"), + resolved_path=pathlib.Path("renders_v001"), absolute_path=missing_dir, ) - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=str(missing_dir)) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) - dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = directory_mod.DirectoryDestination( + macro_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW + ) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() assert directory.location == "{outputs}/renders_v{_index:03}" def test_versioning_index_request_failure_raises_directory_error(self) -> None: - index_failure = GetNextVersionIndexResultFailure( + index_failure = os_events.GetNextVersionIndexResultFailure( result_details="Failed to determine next index", - failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES, + failure_reason=os_events.FileIOFailureReason.MISSING_MACRO_VARIABLES, + ) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = directory_mod.DirectoryDestination( + macro_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW ) - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) - dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, return_value=index_failure), pytest.raises(DirectoryError): + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_failure), pytest.raises(directory_mod.DirectoryError): dest.create() def test_versioning_macro_resolve_failure_raises_directory_error(self) -> None: - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - resolve_failure = GetPathForMacroResultFailure( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_failure = project_events.GetPathForMacroResultFailure( result_details="Macro resolution failed", - failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, + failure_reason=project_events.PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, missing_variables={"outputs"}, ) - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) - dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = directory_mod.DirectoryDestination( + macro_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW + ) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_failure]), pytest.raises(DirectoryError): + with ( + mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_failure]), + pytest.raises(directory_mod.DirectoryError), + ): dest.create() - def test_versioning_mkdir_failure_raises_directory_error(self, tmp_path: Path) -> None: + def test_versioning_mkdir_failure_raises_directory_error(self, tmp_path: pathlib.Path) -> None: missing_dir = tmp_path / "renders_v001" - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - resolve_result = GetPathForMacroResultSuccess( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_v001"), + resolved_path=pathlib.Path("renders_v001"), absolute_path=missing_dir, ) - mkdir_failure = MakeDirectoryResultFailure( + mkdir_failure = os_events.MakeDirectoryResultFailure( result_details="Directory already exists", - failure_reason=FileIOFailureReason.POLICY_NO_OVERWRITE, + failure_reason=os_events.FileIOFailureReason.POLICY_NO_OVERWRITE, ) - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}"), {}) - dest = DirectoryDestination(macro_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}"), {}) + dest = directory_mod.DirectoryDestination( + macro_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW + ) with ( - patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_failure]), - pytest.raises(DirectoryError), + mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_failure]), + pytest.raises(directory_mod.DirectoryError), ): dest.create() - def test_create_new_plain_string_appends_index(self, tmp_path: Path) -> None: + def test_create_new_plain_string_appends_index(self, tmp_path: pathlib.Path) -> None: """Regression: CREATE_NEW with a plain string must use versioning, not silently reuse the directory.""" dir_path = str(tmp_path / "renders") versioned_dir = tmp_path / "renders_1" - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - resolve_result = GetPathForMacroResultSuccess( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_1"), + resolved_path=pathlib.Path("renders_1"), absolute_path=versioned_dir, ) - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + dest = directory_mod.DirectoryDestination(dir_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() assert directory.resolve() == versioned_dir - def test_create_new_plain_string_increments_for_next_run(self, tmp_path: Path) -> None: + def test_create_new_plain_string_increments_for_next_run(self, tmp_path: pathlib.Path) -> None: """Regression: second CREATE_NEW call on the same plain-string base uses index=2, not index=1.""" dir_path = str(tmp_path / "renders") versioned_dir = tmp_path / "renders_2" - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=2) - resolve_result = GetPathForMacroResultSuccess( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=2) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_2"), + resolved_path=pathlib.Path("renders_2"), absolute_path=versioned_dir, ) - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - dest = DirectoryDestination(dir_path, existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + dest = directory_mod.DirectoryDestination(dir_path, existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() assert directory.resolve() == versioned_dir def test_create_new_plain_string_index_request_failure_raises(self) -> None: - index_failure = GetNextVersionIndexResultFailure( + index_failure = os_events.GetNextVersionIndexResultFailure( result_details="Failed to determine next index", - failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES, + failure_reason=os_events.FileIOFailureReason.MISSING_MACRO_VARIABLES, + ) + dest = directory_mod.DirectoryDestination( + "/some/path/renders", existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW ) - dest = DirectoryDestination("/some/path/renders", existing_dir_policy=ExistingFilePolicy.CREATE_NEW) - with patch(HANDLE_REQUEST_PATH, return_value=index_failure), pytest.raises(DirectoryError): + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_failure), pytest.raises(directory_mod.DirectoryError): dest.create() - def test_create_new_macro_template_string_uses_versioning(self, tmp_path: Path) -> None: + def test_create_new_macro_template_string_uses_versioning(self, tmp_path: pathlib.Path) -> None: """A macro template string passed directly to DirectoryDestination uses versioning. A string with variables but no {_index} is treated as a MacroPath for versioning. """ versioned_dir = tmp_path / "renders_v001" - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - resolve_result = GetPathForMacroResultSuccess( + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + resolve_result = project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("renders_v001"), + resolved_path=pathlib.Path("renders_v001"), absolute_path=versioned_dir, ) - mkdir_result = MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) - map_result = AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) + mkdir_result = os_events.MakeDirectoryResultSuccess(result_details="OK", created_path=str(versioned_dir)) + map_result = project_events.AttemptMapAbsolutePathToProjectResultSuccess(result_details="OK", mapped_path=None) - dest = DirectoryDestination("{outputs}/renders_v{_index:03}", existing_dir_policy=ExistingFilePolicy.CREATE_NEW) + dest = directory_mod.DirectoryDestination( + "{outputs}/renders_v{_index:03}", existing_dir_policy=os_events.ExistingFilePolicy.CREATE_NEW + ) - with patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[index_result, resolve_result, mkdir_result, map_result]): directory = dest.create() assert directory.location == "{outputs}/renders_v{_index:03}" diff --git a/tests/unit/files/test_file_sequence.py b/tests/unit/files/test_file_sequence.py index fbe3b0e6ee..22dc42020a 100644 --- a/tests/unit/files/test_file_sequence.py +++ b/tests/unit/files/test_file_sequence.py @@ -1,97 +1,78 @@ """Unit tests for FileSequence and FileSequenceDestination.""" -from pathlib import Path -from unittest.mock import patch +import pathlib +from unittest import mock import pytest -from griptape_nodes.common.macro_parser import ParsedMacro -from griptape_nodes.common.sequences import MissingItemPolicy, Sequence, SequenceEntry -from griptape_nodes.files.file_sequence import ( - FileSequence, - FileSequenceDestination, - FileSequenceError, - build_versioned_sequence_destination, - entry_macro_to_hash_pattern, - hash_pattern_to_entry_macro, -) -from griptape_nodes.retained_mode.events.os_events import ( - ExistingFilePolicy, - FileIOFailureReason, - GetNextVersionIndexRequest, - GetNextVersionIndexResultFailure, - GetNextVersionIndexResultSuccess, - ScanSequencesRequest, - ScanSequencesResultFailure, - ScanSequencesResultSuccess, - SequenceScanFailureReason, -) -from griptape_nodes.retained_mode.events.project_events import ( - GetPathForMacroResultFailure, - GetPathForMacroResultSuccess, - MacroPath, - PathResolutionFailureReason, -) - -HANDLE_REQUEST_PATH = "griptape_nodes.files.file_sequence.GriptapeNodes.handle_request" +from griptape_nodes.common import macro_parser, sequences +from griptape_nodes.files import file as file_mod +from griptape_nodes.files import file_sequence +from griptape_nodes.retained_mode.events import os_events, project_events + +HANDLE_REQUEST_PATH = "griptape_nodes.retained_mode.griptape_nodes.GriptapeNodes.handle_request" class TestHashPatternConversion: """Tests for hash_pattern_to_entry_macro and entry_macro_to_hash_pattern (pure functions).""" def test_hash_to_entry_macro_four_hashes(self) -> None: - assert hash_pattern_to_entry_macro("####") == "{entry:04}" + assert file_sequence.hash_pattern_to_entry_macro("####") == "{entry:04}" def test_hash_to_entry_macro_two_hashes(self) -> None: - assert hash_pattern_to_entry_macro("##") == "{entry:02}" + assert file_sequence.hash_pattern_to_entry_macro("##") == "{entry:02}" def test_hash_to_entry_macro_six_hashes(self) -> None: - assert hash_pattern_to_entry_macro("######") == "{entry:06}" + assert file_sequence.hash_pattern_to_entry_macro("######") == "{entry:06}" def test_hash_to_entry_macro_with_prefix_and_suffix(self) -> None: - assert hash_pattern_to_entry_macro("render_####.exr") == "render_{entry:04}.exr" + assert file_sequence.hash_pattern_to_entry_macro("render_####.exr") == "render_{entry:04}.exr" def test_hash_to_entry_macro_no_hashes_unchanged(self) -> None: - assert hash_pattern_to_entry_macro("frame.exr") == "frame.exr" + assert file_sequence.hash_pattern_to_entry_macro("frame.exr") == "frame.exr" def test_hash_to_entry_macro_single_hash(self) -> None: - assert hash_pattern_to_entry_macro("#") == "{entry:01}" + assert file_sequence.hash_pattern_to_entry_macro("#") == "{entry:01}" def test_entry_macro_to_hash_with_explicit_width(self) -> None: - assert entry_macro_to_hash_pattern("{entry:06}") == "######" + assert file_sequence.entry_macro_to_hash_pattern("{entry:06}") == "######" def test_entry_macro_to_hash_four_width(self) -> None: - assert entry_macro_to_hash_pattern("{entry:04}") == "####" + assert file_sequence.entry_macro_to_hash_pattern("{entry:04}") == "####" def test_entry_macro_to_hash_no_width_defaults_to_4(self) -> None: - assert entry_macro_to_hash_pattern("{entry}") == "####" + assert file_sequence.entry_macro_to_hash_pattern("{entry}") == "####" def test_entry_macro_to_hash_with_prefix_and_suffix(self) -> None: - assert entry_macro_to_hash_pattern("frame_{entry:04}.exr") == "frame_####.exr" + assert file_sequence.entry_macro_to_hash_pattern("frame_{entry:04}.exr") == "frame_####.exr" def test_entry_macro_to_hash_no_entry_var_unchanged(self) -> None: - assert entry_macro_to_hash_pattern("frame.exr") == "frame.exr" + assert file_sequence.entry_macro_to_hash_pattern("frame.exr") == "frame.exr" def test_roundtrip_hash_to_macro_to_hash(self) -> None: original = "render_####.exr" - assert entry_macro_to_hash_pattern(hash_pattern_to_entry_macro(original)) == original + assert ( + file_sequence.entry_macro_to_hash_pattern(file_sequence.hash_pattern_to_entry_macro(original)) == original + ) def test_roundtrip_macro_to_hash_to_macro(self) -> None: original = "render_{entry:04}.exr" - assert hash_pattern_to_entry_macro(entry_macro_to_hash_pattern(original)) == original + assert ( + file_sequence.hash_pattern_to_entry_macro(file_sequence.entry_macro_to_hash_pattern(original)) == original + ) def test_hash_to_entry_macro_printf_pattern(self) -> None: - assert hash_pattern_to_entry_macro("render_%04d.exr") == "render_{entry:04}.exr" + assert file_sequence.hash_pattern_to_entry_macro("render_%04d.exr") == "render_{entry:04}.exr" def test_hash_to_entry_macro_full_path_with_parent_dir(self) -> None: assert ( - hash_pattern_to_entry_macro("{outputs}/renders/render_####.exr") + file_sequence.hash_pattern_to_entry_macro("{outputs}/renders/render_####.exr") == "{outputs}/renders/render_{entry:04}.exr" ) def test_hash_to_entry_macro_printf_in_full_path(self) -> None: assert ( - hash_pattern_to_entry_macro("{outputs}/renders/render_%04d.exr") + file_sequence.hash_pattern_to_entry_macro("{outputs}/renders/render_%04d.exr") == "{outputs}/renders/render_{entry:04}.exr" ) @@ -100,14 +81,18 @@ class TestFileSequenceConstructor: """Tests that FileSequence constructor stores the entry macro without I/O.""" def test_stores_entry_macro(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + seq = file_sequence.FileSequence(macro_path) assert seq._entry_macro is macro_path def test_does_no_io(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - with patch(HANDLE_REQUEST_PATH) as mock_handle: - FileSequence(macro_path) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: + file_sequence.FileSequence(macro_path) mock_handle.assert_not_called() @@ -116,14 +101,14 @@ class TestFileSequenceLocation: def test_location_returns_macro_template(self) -> None: template = "{outputs}/frames/frame_{entry:04}.exr" - macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(template), {"_index": 1}) + seq = file_sequence.FileSequence(macro_path) assert seq.location == template def test_location_no_io_performed(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) - with patch(HANDLE_REQUEST_PATH) as mock_handle: - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: + seq = file_sequence.FileSequence(macro_path) _ = seq.location mock_handle.assert_not_called() @@ -133,14 +118,14 @@ class TestFileSequencePattern: def test_pattern_converts_entry_var_to_hashes(self) -> None: template = "{outputs}/frames/frame_{entry:04}.exr" - macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(template), {"_index": 1}) + seq = file_sequence.FileSequence(macro_path) assert seq.pattern == "{outputs}/frames/frame_####.exr" def test_pattern_with_six_digit_entry(self) -> None: template = "renders/frame_{entry:06}.png" - macro_path = MacroPath(ParsedMacro(template), {}) - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(template), {}) + seq = file_sequence.FileSequence(macro_path) assert seq.pattern == "renders/frame_######.png" @@ -149,32 +134,32 @@ class TestFileSequenceDirectory: def test_directory_returns_parent_of_template(self) -> None: template = "{outputs}/frames/frame_{entry:04}.exr" - macro_path = MacroPath(ParsedMacro(template), {"_index": 1}) - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(template), {"_index": 1}) + seq = file_sequence.FileSequence(macro_path) directory = seq.directory assert directory.location == "{outputs}/frames" def test_directory_preserves_locked_index_variable(self) -> None: locked_index = 2 template = "{outputs}/renders_v{_index:03}/frame_{entry:04}.exr" - macro_path = MacroPath(ParsedMacro(template), {"_index": locked_index}) - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(template), {"_index": locked_index}) + seq = file_sequence.FileSequence(macro_path) directory = seq.directory - assert isinstance(directory._dir_path, MacroPath) + assert isinstance(directory._dir_path, project_events.MacroPath) assert directory._dir_path.variables["_index"] == locked_index def test_directory_does_not_carry_entry_variable(self) -> None: template = "{outputs}/frames/frame_{entry:04}.exr" - macro_path = MacroPath(ParsedMacro(template), {"_index": 1, "entry": 5}) - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(template), {"_index": 1, "entry": 5}) + seq = file_sequence.FileSequence(macro_path) directory = seq.directory - assert isinstance(directory._dir_path, MacroPath) + assert isinstance(directory._dir_path, project_events.MacroPath) assert "entry" not in directory._dir_path.variables def test_directory_no_io_performed(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) - with patch(HANDLE_REQUEST_PATH) as mock_handle: - seq = FileSequence(macro_path) + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: + seq = file_sequence.FileSequence(macro_path) _ = seq.directory mock_handle.assert_not_called() @@ -184,114 +169,121 @@ class TestFileSequenceEntry: def test_entry_returns_file_with_correct_entry_number(self) -> None: entry_number = 5 - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - seq = FileSequence(macro_path) - file = seq.entry(entry_number) - assert isinstance(file._file_path, MacroPath) - assert file._file_path.variables["entry"] == entry_number + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + seq = file_sequence.FileSequence(macro_path) + f = seq.entry(entry_number) + assert isinstance(f._file_path, project_events.MacroPath) + assert f._file_path.variables["entry"] == entry_number def test_entry_inherits_locked_index_variable(self) -> None: locked_index = 7 entry_number = 3 - macro_path = MacroPath( - ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {"_index": locked_index} + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {"_index": locked_index} ) - seq = FileSequence(macro_path) - file = seq.entry(entry_number) - assert isinstance(file._file_path, MacroPath) - assert file._file_path.variables["_index"] == locked_index - assert file._file_path.variables["entry"] == entry_number + seq = file_sequence.FileSequence(macro_path) + f = seq.entry(entry_number) + assert isinstance(f._file_path, project_events.MacroPath) + assert f._file_path.variables["_index"] == locked_index + assert f._file_path.variables["entry"] == entry_number def test_entry_does_no_io(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - seq = FileSequence(macro_path) - with patch(HANDLE_REQUEST_PATH) as mock_handle: + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + seq = file_sequence.FileSequence(macro_path) + with mock.patch(HANDLE_REQUEST_PATH) as mock_handle: seq.entry(0) mock_handle.assert_not_called() def test_entry_zero(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) - seq = FileSequence(macro_path) - file = seq.entry(0) - assert isinstance(file._file_path, MacroPath) - assert file._file_path.variables["entry"] == 0 + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) + seq = file_sequence.FileSequence(macro_path) + f = seq.entry(0) + assert isinstance(f._file_path, project_events.MacroPath) + assert f._file_path.variables["entry"] == 0 class TestFileSequenceDestination: """Tests for FileSequenceDestination.""" def test_file_sequence_is_none_before_write(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - dest = FileSequenceDestination(macro_path) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + dest = file_sequence.FileSequenceDestination(macro_path) assert dest.file_sequence is None def test_entry_returns_file_destination(self) -> None: - from griptape_nodes.files.file import FileDestination - - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - dest = FileSequenceDestination(macro_path) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + dest = file_sequence.FileSequenceDestination(macro_path) entry_dest = dest.entry(1) - assert isinstance(entry_dest, FileDestination) + assert isinstance(entry_dest, file_mod.FileDestination) def test_entry_destination_has_correct_entry_variable(self) -> None: entry_number = 42 - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - dest = FileSequenceDestination(macro_path) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + dest = file_sequence.FileSequenceDestination(macro_path) entry_dest = dest.entry(entry_number) - assert isinstance(entry_dest._file._file_path, MacroPath) + assert isinstance(entry_dest._file._file_path, project_events.MacroPath) assert entry_dest._file._file_path.variables["entry"] == entry_number def test_on_entry_written_sets_file_sequence(self) -> None: - from griptape_nodes.files.file import File - - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - dest = FileSequenceDestination(macro_path) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + dest = file_sequence.FileSequenceDestination(macro_path) assert dest.file_sequence is None - dest._on_entry_written(File("workspace/frame_0001.exr")) + dest._on_entry_written(file_mod.File("workspace/frame_0001.exr")) assert dest.file_sequence is not None - assert isinstance(dest.file_sequence, FileSequence) + assert isinstance(dest.file_sequence, file_sequence.FileSequence) def test_file_sequence_not_reset_on_second_write(self) -> None: - from griptape_nodes.files.file import File - - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - dest = FileSequenceDestination(macro_path) - dest._on_entry_written(File("workspace/frame_0001.exr")) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + dest = file_sequence.FileSequenceDestination(macro_path) + dest._on_entry_written(file_mod.File("workspace/frame_0001.exr")) first_seq = dest.file_sequence - dest._on_entry_written(File("workspace/frame_0002.exr")) + dest._on_entry_written(file_mod.File("workspace/frame_0002.exr")) assert dest.file_sequence is first_seq def test_file_sequence_uses_locked_macro(self) -> None: - from griptape_nodes.files.file import File - - macro_path = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {"_index": 3}) - dest = FileSequenceDestination(macro_path) - dest._on_entry_written(File("workspace/frame_0001.exr")) + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {"_index": 3} + ) + dest = file_sequence.FileSequenceDestination(macro_path) + dest._on_entry_written(file_mod.File("workspace/frame_0001.exr")) assert dest.file_sequence is not None assert dest.file_sequence._entry_macro is macro_path def test_defaults_overwrite_policy(self) -> None: - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) - dest = FileSequenceDestination(macro_path) - assert dest._existing_file_policy == ExistingFilePolicy.OVERWRITE + macro_path = project_events.MacroPath(macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {}) + dest = file_sequence.FileSequenceDestination(macro_path) + assert dest._existing_file_policy == os_events.ExistingFilePolicy.OVERWRITE assert dest._create_parents is True def test_entry_write_destination_triggers_on_written_callback(self) -> None: - from griptape_nodes.files.file import File - from griptape_nodes.files.file_sequence import _EntryWriteDestination - - callback_calls: list[File] = [] - macro_path = MacroPath(ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1}) - entry_path = MacroPath(macro_path.parsed_macro, {**macro_path.variables, "entry": 1}) + callback_calls: list[file_mod.File] = [] + macro_path = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/frames/frame_{entry:04}.exr"), {"_index": 1} + ) + entry_path = project_events.MacroPath(macro_path.parsed_macro, {**macro_path.variables, "entry": 1}) - entry_dest = _EntryWriteDestination( + entry_dest = file_sequence._EntryWriteDestination( entry_path, - existing_file_policy=ExistingFilePolicy.OVERWRITE, + existing_file_policy=os_events.ExistingFilePolicy.OVERWRITE, create_parents=True, on_written=callback_calls.append, ) - written_file = File("workspace/frame_0001.exr") + written_file = file_mod.File("workspace/frame_0001.exr") entry_dest._on_written(written_file) assert len(callback_calls) == 1 @@ -308,25 +300,25 @@ class TestFileSequenceScan: _TEMPLATE = "{outputs}/frames/frame_{entry:04}.exr" - def _path_success(self, abs_dir: Path) -> GetPathForMacroResultSuccess: - return GetPathForMacroResultSuccess( + def _path_success(self, abs_dir: pathlib.Path) -> project_events.GetPathForMacroResultSuccess: + return project_events.GetPathForMacroResultSuccess( result_details="OK", - resolved_path=Path("frames/frame_0000.exr"), + resolved_path=pathlib.Path("frames/frame_0000.exr"), absolute_path=abs_dir / "frame_0000.exr", ) - def _scan_success(self, sequences: list[Sequence] | None = None) -> ScanSequencesResultSuccess: - seqs = sequences or [] - return ScanSequencesResultSuccess( + def _scan_success(self, seqs: list[sequences.Sequence] | None = None) -> os_events.ScanSequencesResultSuccess: + found = seqs or [] + return os_events.ScanSequencesResultSuccess( result_details="ok", - sequences=seqs, - has_entries=any(s.entries for s in seqs), - directory_had_matching_files=bool(seqs), + sequences=found, + has_entries=any(s.entries for s in found), + directory_had_matching_files=bool(found), ) - def _make_sequence(self, abs_dir: Path) -> Sequence: - return Sequence( - entries=[SequenceEntry(number=1, padded_number="0001", path=str(abs_dir / "frame_0001.exr"))], + def _make_sequence(self, abs_dir: pathlib.Path) -> sequences.Sequence: + return sequences.Sequence( + entries=[sequences.SequenceEntry(number=1, padded_number="0001", path=str(abs_dir / "frame_0001.exr"))], first=1, last=1, discovered_first=1, @@ -334,87 +326,87 @@ def _make_sequence(self, abs_dir: Path) -> Sequence: padding=4, pattern="frame_####.exr", directory=str(abs_dir), - policy=MissingItemPolicy.SPLIT, + policy=sequences.MissingItemPolicy.SPLIT, present_numbers={1}, ) def test_returns_empty_list_when_macro_resolution_fails(self) -> None: - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - failure = GetPathForMacroResultFailure( + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) + failure = project_events.GetPathForMacroResultFailure( result_details="missing outputs", - failure_reason=PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, + failure_reason=project_events.PathResolutionFailureReason.MISSING_REQUIRED_VARIABLES, ) - with patch(HANDLE_REQUEST_PATH, return_value=failure): + with mock.patch(HANDLE_REQUEST_PATH, return_value=failure): assert seq.scan() == [] - def test_returns_empty_list_when_scan_request_fails(self, tmp_path: Path) -> None: - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - scan_failure = ScanSequencesResultFailure( + def test_returns_empty_list_when_scan_request_fails(self, tmp_path: pathlib.Path) -> None: + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) + scan_failure = os_events.ScanSequencesResultFailure( result_details="listing error", - failure_reason=SequenceScanFailureReason.INVALID_TEMPLATE, + failure_reason=os_events.SequenceScanFailureReason.INVALID_TEMPLATE, ) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), scan_failure]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), scan_failure]): assert seq.scan() == [] - def test_returns_sequences_on_success(self, tmp_path: Path) -> None: - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) + def test_returns_sequences_on_success(self, tmp_path: pathlib.Path) -> None: + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) expected = [self._make_sequence(tmp_path)] - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success(expected)]): + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success(expected)]): result = seq.scan() assert result == expected - def test_returns_empty_list_when_no_sequences_found(self, tmp_path: Path) -> None: - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()]): + def test_returns_empty_list_when_no_sequences_found(self, tmp_path: pathlib.Path) -> None: + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) + with mock.patch(HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()]): assert seq.scan() == [] - def test_dispatches_resolved_directory_to_scan_request(self, tmp_path: Path) -> None: - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch( + def test_dispatches_resolved_directory_to_scan_request(self, tmp_path: pathlib.Path) -> None: + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) + with mock.patch( HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] ) as mock_handle: seq.scan() scan_request = mock_handle.call_args_list[1][0][0] - assert isinstance(scan_request, ScanSequencesRequest) + assert isinstance(scan_request, os_events.ScanSequencesRequest) assert scan_request.directory == str(tmp_path) - def test_dispatches_filename_only_pattern_to_scan_request(self, tmp_path: Path) -> None: - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch( + def test_dispatches_filename_only_pattern_to_scan_request(self, tmp_path: pathlib.Path) -> None: + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) + with mock.patch( HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] ) as mock_handle: seq.scan() scan_request = mock_handle.call_args_list[1][0][0] - assert isinstance(scan_request, ScanSequencesRequest) + assert isinstance(scan_request, os_events.ScanSequencesRequest) assert scan_request.pattern == "frame_####.exr" - def test_forwards_policy_to_scan_request(self, tmp_path: Path) -> None: - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch( + def test_forwards_policy_to_scan_request(self, tmp_path: pathlib.Path) -> None: + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) + with mock.patch( HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] ) as mock_handle: - seq.scan(policy=MissingItemPolicy.SKIP) + seq.scan(policy=sequences.MissingItemPolicy.SKIP) scan_request = mock_handle.call_args_list[1][0][0] - assert isinstance(scan_request, ScanSequencesRequest) - assert scan_request.policy == MissingItemPolicy.SKIP + assert isinstance(scan_request, os_events.ScanSequencesRequest) + assert scan_request.policy == sequences.MissingItemPolicy.SKIP - def test_forwards_start_and_end_to_scan_request(self, tmp_path: Path) -> None: + def test_forwards_start_and_end_to_scan_request(self, tmp_path: pathlib.Path) -> None: start, end = 2, 10 - seq = FileSequence(MacroPath(ParsedMacro(self._TEMPLATE), {})) - with patch( + seq = file_sequence.FileSequence(project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {})) + with mock.patch( HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] ) as mock_handle: seq.scan(start=start, end=end) scan_request = mock_handle.call_args_list[1][0][0] - assert isinstance(scan_request, ScanSequencesRequest) + assert isinstance(scan_request, os_events.ScanSequencesRequest) assert scan_request.start_number == start assert scan_request.end_number == end - def test_probe_macro_includes_entry_zero(self, tmp_path: Path) -> None: + def test_probe_macro_includes_entry_zero(self, tmp_path: pathlib.Path) -> None: locked_index = 3 - macro_path = MacroPath(ParsedMacro(self._TEMPLATE), {"_index": locked_index}) - seq = FileSequence(macro_path) - with patch( + macro_path = project_events.MacroPath(macro_parser.ParsedMacro(self._TEMPLATE), {"_index": locked_index}) + seq = file_sequence.FileSequence(macro_path) + with mock.patch( HANDLE_REQUEST_PATH, side_effect=[self._path_success(tmp_path), self._scan_success()] ) as mock_handle: seq.scan() @@ -427,70 +419,86 @@ class TestBuildVersionedSequenceDestination: """Tests for build_versioned_sequence_destination.""" def test_first_version_used_when_engine_returns_index_one(self) -> None: - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + macro = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {} + ) - with patch(HANDLE_REQUEST_PATH, return_value=index_result): - dest = build_versioned_sequence_destination(macro) + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_result): + dest = file_sequence.build_versioned_sequence_destination(macro) assert dest._entry_macro.variables["_index"] == 1 def test_uses_index_returned_by_engine(self) -> None: expected_index = 3 - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=expected_index) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=expected_index) + macro = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {} + ) - with patch(HANDLE_REQUEST_PATH, return_value=index_result): - dest = build_versioned_sequence_destination(macro) + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_result): + dest = file_sequence.build_versioned_sequence_destination(macro) assert dest._entry_macro.variables["_index"] == expected_index def test_none_index_treated_as_one(self) -> None: - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=None) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=None) + macro = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {} + ) - with patch(HANDLE_REQUEST_PATH, return_value=index_result): - dest = build_versioned_sequence_destination(macro) + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_result): + dest = file_sequence.build_versioned_sequence_destination(macro) assert dest._entry_macro.variables["_index"] == 1 def test_raises_when_index_request_fails(self) -> None: - failure = GetNextVersionIndexResultFailure( + failure = os_events.GetNextVersionIndexResultFailure( result_details="Failed to determine next index", - failure_reason=FileIOFailureReason.MISSING_MACRO_VARIABLES, + failure_reason=os_events.FileIOFailureReason.MISSING_MACRO_VARIABLES, + ) + macro = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {} ) - macro = MacroPath(ParsedMacro("{outputs}/renders_v{_index:03}/frame_{entry:04}.exr"), {}) - with patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(FileSequenceError): - build_versioned_sequence_destination(macro) + with mock.patch(HANDLE_REQUEST_PATH, return_value=failure), pytest.raises(file_sequence.FileSequenceError): + file_sequence.build_versioned_sequence_destination(macro) def test_locks_index_into_returned_destination_variables(self) -> None: - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {"extra": "value"}) + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + macro = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {"extra": "value"} + ) - with patch(HANDLE_REQUEST_PATH, return_value=index_result): - dest = build_versioned_sequence_destination(macro) + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_result): + dest = file_sequence.build_versioned_sequence_destination(macro) assert "_index" in dest._entry_macro.variables assert "extra" in dest._entry_macro.variables assert "entry" not in dest._entry_macro.variables def test_existing_file_policy_forwarded(self) -> None: - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {}) + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + macro = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {} + ) - with patch(HANDLE_REQUEST_PATH, return_value=index_result): - dest = build_versioned_sequence_destination(macro, existing_file_policy=ExistingFilePolicy.FAIL) + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_result): + dest = file_sequence.build_versioned_sequence_destination( + macro, existing_file_policy=os_events.ExistingFilePolicy.FAIL + ) - assert dest._existing_file_policy == ExistingFilePolicy.FAIL + assert dest._existing_file_policy == os_events.ExistingFilePolicy.FAIL def test_passes_directory_macro_without_entry_variable(self) -> None: - index_result = GetNextVersionIndexResultSuccess(result_details="OK", index=1) - macro = MacroPath(ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {"extra": "val"}) + index_result = os_events.GetNextVersionIndexResultSuccess(result_details="OK", index=1) + macro = project_events.MacroPath( + macro_parser.ParsedMacro("{outputs}/seq_v{_index:03}/frame_{entry:04}.exr"), {"extra": "val"} + ) - with patch(HANDLE_REQUEST_PATH, return_value=index_result) as mock_handle: - build_versioned_sequence_destination(macro) + with mock.patch(HANDLE_REQUEST_PATH, return_value=index_result) as mock_handle: + file_sequence.build_versioned_sequence_destination(macro) request = mock_handle.call_args[0][0] - assert isinstance(request, GetNextVersionIndexRequest) + assert isinstance(request, os_events.GetNextVersionIndexRequest) assert "entry" not in request.macro_path.variables From 8a3b204252bb6969958d0ef427217de1c458f8be Mon Sep 17 00:00:00 2001 From: Alex Rumsey Date: Fri, 12 Jun 2026 13:22:39 +0100 Subject: [PATCH 22/22] fix: update file_sequence with new ScanSequencesRequest signature --- src/griptape_nodes/files/file_sequence.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/griptape_nodes/files/file_sequence.py b/src/griptape_nodes/files/file_sequence.py index 6a99c5f438..f77be01191 100644 --- a/src/griptape_nodes/files/file_sequence.py +++ b/src/griptape_nodes/files/file_sequence.py @@ -131,7 +131,6 @@ def scan( ) if not isinstance(resolve_result, project_events.GetPathForMacroResultSuccess): return [] - resolved_dir = str(resolve_result.absolute_path.parent) filename_template = pathlib.PurePosixPath(self.location).name entry_match = _ENTRY_MACRO_PATTERN.search(filename_template) entry_width = int(entry_match.group(1)) if entry_match and entry_match.group(1) else 4 @@ -139,8 +138,7 @@ def scan( filename_pattern = resolve_result.absolute_path.name.replace(entry_zero_str, "#" * entry_width, 1) scan_result = griptape_nodes_mod.GriptapeNodes.handle_request( os_events.ScanSequencesRequest( - directory=resolved_dir, - pattern=filename_pattern, + path=str(resolve_result.absolute_path.parent / filename_pattern), policy=policy, start_number=start, end_number=end,