griptape-nodes 0.53.0__py3-none-any.whl → 0.54.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. griptape_nodes/__init__.py +5 -2
  2. griptape_nodes/app/app.py +4 -26
  3. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +35 -5
  4. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +15 -1
  5. griptape_nodes/cli/commands/config.py +4 -1
  6. griptape_nodes/cli/commands/init.py +5 -3
  7. griptape_nodes/cli/commands/libraries.py +14 -8
  8. griptape_nodes/cli/commands/models.py +504 -0
  9. griptape_nodes/cli/commands/self.py +5 -2
  10. griptape_nodes/cli/main.py +11 -1
  11. griptape_nodes/cli/shared.py +0 -9
  12. griptape_nodes/common/directed_graph.py +17 -1
  13. griptape_nodes/drivers/storage/base_storage_driver.py +40 -20
  14. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +24 -29
  15. griptape_nodes/drivers/storage/local_storage_driver.py +17 -13
  16. griptape_nodes/exe_types/node_types.py +219 -14
  17. griptape_nodes/exe_types/param_components/__init__.py +1 -0
  18. griptape_nodes/exe_types/param_components/execution_status_component.py +138 -0
  19. griptape_nodes/machines/control_flow.py +129 -92
  20. griptape_nodes/machines/dag_builder.py +207 -0
  21. griptape_nodes/machines/parallel_resolution.py +264 -276
  22. griptape_nodes/machines/sequential_resolution.py +9 -7
  23. griptape_nodes/node_library/library_registry.py +34 -1
  24. griptape_nodes/retained_mode/events/app_events.py +5 -1
  25. griptape_nodes/retained_mode/events/base_events.py +7 -7
  26. griptape_nodes/retained_mode/events/config_events.py +30 -0
  27. griptape_nodes/retained_mode/events/execution_events.py +2 -2
  28. griptape_nodes/retained_mode/events/model_events.py +296 -0
  29. griptape_nodes/retained_mode/griptape_nodes.py +10 -1
  30. griptape_nodes/retained_mode/managers/agent_manager.py +14 -0
  31. griptape_nodes/retained_mode/managers/config_manager.py +44 -3
  32. griptape_nodes/retained_mode/managers/event_manager.py +8 -2
  33. griptape_nodes/retained_mode/managers/flow_manager.py +45 -14
  34. griptape_nodes/retained_mode/managers/library_manager.py +3 -3
  35. griptape_nodes/retained_mode/managers/model_manager.py +1107 -0
  36. griptape_nodes/retained_mode/managers/node_manager.py +26 -26
  37. griptape_nodes/retained_mode/managers/object_manager.py +1 -1
  38. griptape_nodes/retained_mode/managers/os_manager.py +6 -6
  39. griptape_nodes/retained_mode/managers/settings.py +87 -9
  40. griptape_nodes/retained_mode/managers/static_files_manager.py +77 -9
  41. griptape_nodes/retained_mode/managers/sync_manager.py +10 -5
  42. griptape_nodes/retained_mode/managers/workflow_manager.py +101 -92
  43. griptape_nodes/retained_mode/retained_mode.py +19 -0
  44. griptape_nodes/servers/__init__.py +1 -0
  45. griptape_nodes/{mcp_server/server.py → servers/mcp.py} +1 -1
  46. griptape_nodes/{app/api.py → servers/static.py} +43 -40
  47. griptape_nodes/traits/button.py +124 -6
  48. griptape_nodes/traits/multi_options.py +188 -0
  49. griptape_nodes/traits/numbers_selector.py +77 -0
  50. griptape_nodes/traits/options.py +93 -2
  51. griptape_nodes/utils/async_utils.py +31 -0
  52. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/METADATA +3 -1
  53. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/RECORD +56 -47
  54. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/WHEEL +1 -1
  55. /griptape_nodes/{mcp_server → servers}/ws_request_manager.py +0 -0
  56. {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  from dataclasses import dataclass, field
3
- from typing import TYPE_CHECKING, Literal
3
+ from typing import TYPE_CHECKING, Literal, get_args
4
4
 
5
5
  from griptape_nodes.exe_types.core_types import NodeMessagePayload, NodeMessageResult, Trait
6
6
 
@@ -63,15 +63,22 @@ class OnClickMessageResultPayload(NodeMessagePayload):
63
63
  button_details: ButtonDetailsMessagePayload
64
64
 
65
65
 
66
+ class SetButtonStatusMessagePayload(NodeMessagePayload):
67
+ """Payload for setting button status with explicit field updates."""
68
+
69
+ updates: dict[str, str | bool | None]
70
+
71
+
66
72
  @dataclass(eq=False)
67
73
  class Button(Trait):
68
74
  # Specific callback types for better type safety and clarity
69
- type OnClickCallback = Callable[[Button, ButtonDetailsMessagePayload], NodeMessageResult]
70
- type GetButtonStateCallback = Callable[[Button, ButtonDetailsMessagePayload], NodeMessageResult]
75
+ type OnClickCallback = Callable[[Button, ButtonDetailsMessagePayload], NodeMessageResult | None]
76
+ type GetButtonStateCallback = Callable[[Button, ButtonDetailsMessagePayload], NodeMessageResult | None]
71
77
 
72
78
  # Static message type constants
73
79
  ON_CLICK_MESSAGE_TYPE = "on_click"
74
80
  GET_BUTTON_STATUS_MESSAGE_TYPE = "get_button_status"
81
+ SET_BUTTON_STATUS_MESSAGE_TYPE = "set_button_status"
75
82
 
76
83
  # Button styling and behavior properties
77
84
  label: str = "Button"
@@ -169,7 +176,7 @@ class Button(Trait):
169
176
 
170
177
  return options
171
178
 
172
- def on_message_received(self, message_type: str, message: NodeMessagePayload | None) -> NodeMessageResult | None:
179
+ def on_message_received(self, message_type: str, message: NodeMessagePayload | None) -> NodeMessageResult | None: # noqa: PLR0911
173
180
  """Handle messages sent to this button trait.
174
181
 
175
182
  Args:
@@ -185,7 +192,16 @@ class Button(Trait):
185
192
  try:
186
193
  # Pre-fill button details with current state and pass to callback
187
194
  button_details = self.get_button_details()
188
- return self.on_click_callback(self, button_details)
195
+ result = self.on_click_callback(self, button_details)
196
+
197
+ # If callback returns None, provide optimistic success result
198
+ if result is None:
199
+ result = NodeMessageResult(
200
+ success=True,
201
+ details=f"Button '{self.label}' clicked successfully",
202
+ response=button_details,
203
+ )
204
+ return result # noqa: TRY300
189
205
  except Exception as e:
190
206
  return NodeMessageResult(
191
207
  success=False,
@@ -202,7 +218,17 @@ class Button(Trait):
202
218
  try:
203
219
  # Pre-fill button details with current state and pass to callback
204
220
  button_details = self.get_button_details()
205
- return self.get_button_state_callback(self, button_details)
221
+ result = self.get_button_state_callback(self, button_details)
222
+
223
+ # If callback returns None, provide optimistic success result
224
+ if result is None:
225
+ result = NodeMessageResult(
226
+ success=True,
227
+ details=f"Button '{self.label}' state retrieved successfully",
228
+ response=button_details,
229
+ altered_workflow_state=False,
230
+ )
231
+ return result # noqa: TRY300
206
232
  except Exception as e:
207
233
  return NodeMessageResult(
208
234
  success=False,
@@ -212,6 +238,9 @@ class Button(Trait):
212
238
  else:
213
239
  return self._default_get_button_status(message_type, message)
214
240
 
241
+ case self.SET_BUTTON_STATUS_MESSAGE_TYPE:
242
+ return self._handle_set_button_status(message)
243
+
215
244
  # Delegate to parent implementation for unhandled messages or no callback
216
245
  return super().on_message_received(message_type, message)
217
246
 
@@ -229,3 +258,92 @@ class Button(Trait):
229
258
  response=button_details,
230
259
  altered_workflow_state=False,
231
260
  )
261
+
262
+ def _handle_set_button_status(self, message: NodeMessagePayload | None) -> NodeMessageResult: # noqa: C901
263
+ """Handle set button status messages by updating fields specified in the updates dict."""
264
+ if not message:
265
+ return NodeMessageResult(
266
+ success=False,
267
+ details="No message payload provided for set_button_status",
268
+ response=None,
269
+ altered_workflow_state=False,
270
+ )
271
+
272
+ if not isinstance(message, SetButtonStatusMessagePayload):
273
+ return NodeMessageResult(
274
+ success=False,
275
+ details="Invalid message payload type for set_button_status",
276
+ response=None,
277
+ altered_workflow_state=False,
278
+ )
279
+
280
+ # Track which fields were updated
281
+ updated_fields = []
282
+ validation_errors = []
283
+
284
+ # Valid field names and their expected types
285
+ valid_fields = {
286
+ "label": str,
287
+ "variant": str, # Will validate against ButtonVariant literals
288
+ "size": str, # Will validate against ButtonSize literals
289
+ "state": str, # Will validate against ButtonState literals
290
+ "icon": str,
291
+ "icon_class": str,
292
+ "icon_position": str, # Will validate against IconPosition literals
293
+ "full_width": bool,
294
+ "loading_label": str,
295
+ "loading_icon": str,
296
+ "loading_icon_class": str,
297
+ }
298
+
299
+ # Process each update
300
+ for field_name, value in message.updates.items():
301
+ # Check if field is valid
302
+ if field_name not in valid_fields:
303
+ validation_errors.append(f"Invalid field: {field_name}")
304
+ continue
305
+
306
+ # Type check if value is not None
307
+ if value is not None and not isinstance(value, valid_fields[field_name]):
308
+ validation_errors.append(
309
+ f"Invalid type for {field_name}: expected {valid_fields[field_name].__name__}, got {type(value).__name__}"
310
+ )
311
+ continue
312
+
313
+ # Additional validation for Literal types
314
+ if field_name == "variant" and value is not None and value not in get_args(ButtonVariant):
315
+ validation_errors.append(f"Invalid variant: {value}")
316
+ continue
317
+ if field_name == "size" and value is not None and value not in get_args(ButtonSize):
318
+ validation_errors.append(f"Invalid size: {value}")
319
+ continue
320
+ if field_name == "state" and value is not None and value not in get_args(ButtonState):
321
+ validation_errors.append(f"Invalid state: {value}")
322
+ continue
323
+ if field_name == "icon_position" and value is not None and value not in get_args(IconPosition):
324
+ validation_errors.append(f"Invalid icon_position: {value}")
325
+ continue
326
+
327
+ # Update the field
328
+ setattr(self, field_name, value)
329
+ updated_fields.append(field_name)
330
+
331
+ # Return validation errors if any
332
+ if validation_errors:
333
+ return NodeMessageResult(
334
+ success=False,
335
+ details=f"Validation errors: {'; '.join(validation_errors)}",
336
+ response=None,
337
+ altered_workflow_state=False,
338
+ )
339
+
340
+ # Return success with updated button details
341
+ button_details = self.get_button_details()
342
+ fields_str = ", ".join(updated_fields) if updated_fields else "no fields"
343
+
344
+ return NodeMessageResult(
345
+ success=True,
346
+ details=f"Button '{self.label}' updated ({fields_str})",
347
+ response=button_details,
348
+ altered_workflow_state=True,
349
+ )
@@ -0,0 +1,188 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+ from griptape_nodes.exe_types.core_types import Parameter, Trait
6
+
7
+
8
+ @dataclass(eq=False)
9
+ class MultiOptions(Trait):
10
+ # SERIALIZATION BUG FIX EXPLANATION:
11
+ #
12
+ # PROBLEM: Similar to Options trait, MultiOptions had a potential serialization bug
13
+ # where dynamically populated multi-options lists would work correctly during runtime
14
+ # but could revert after save/reload cycles. This happens because:
15
+ # 1. trait.choices was the "source of truth" during runtime
16
+ # 2. ui_options["multi_options"] was populated from trait.choices
17
+ # 3. Only ui_options gets serialized/deserialized (not trait fields)
18
+ # 4. After reload, trait.choices was stale but ui_options had correct data
19
+ # 5. Converters used stale trait.choices, causing validation issues
20
+ #
21
+ # SOLUTION: Make ui_options the primary source of truth, with _choices as fallback
22
+ # 1. choices property reads from ui_options["multi_options"] when available
23
+ # 2. choices setter writes to BOTH _choices and ui_options (dual sync)
24
+ # 3. This ensures serialized ui_options data is used after deserialization
25
+ # 4. _choices provides safety fallback if ui_options is missing/corrupted
26
+
27
+ _choices: list = field(default_factory=lambda: ["choice 1", "choice 2", "choice 3"])
28
+ element_id: str = field(default_factory=lambda: "MultiOptions")
29
+ placeholder: str = field(default="Select options...")
30
+ max_selected_display: int = field(default=3)
31
+ show_search: bool = field(default=True)
32
+ icon_size: str = field(default="small")
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ choices: list | None = None,
38
+ placeholder: str = "Select options...",
39
+ max_selected_display: int = 3,
40
+ show_search: bool = True,
41
+ icon_size: str = "small",
42
+ ) -> None:
43
+ super().__init__()
44
+ # Set choices through property to ensure dual sync from the start
45
+ if choices is not None:
46
+ self.choices = choices
47
+
48
+ self.placeholder = placeholder
49
+ self.max_selected_display = max_selected_display
50
+ self.show_search = show_search
51
+
52
+ # Validate icon_size
53
+ if icon_size not in ["small", "large"]:
54
+ self.icon_size = "small"
55
+ else:
56
+ self.icon_size = icon_size
57
+
58
+ @property
59
+ def choices(self) -> list:
60
+ """Get multi-options choices with ui_options as primary source of truth.
61
+
62
+ CRITICAL: This property prioritizes ui_options["multi_options"]["choices"] over _choices
63
+ because ui_options gets properly serialized/deserialized while trait fields don't.
64
+
65
+ Read priority:
66
+ 1. FIRST: ui_options["multi_options"]["choices"] (survives serialization cycles)
67
+ 2. FALLBACK: _choices field (safety net for edge cases)
68
+
69
+ This prevents bugs where available choices could become stale after reload.
70
+ """
71
+ # Check if we have a parent parameter with ui_options (normal case after trait attachment)
72
+ if self._parent and hasattr(self._parent, "ui_options"):
73
+ ui_options = getattr(self._parent, "ui_options", None)
74
+ if (
75
+ isinstance(ui_options, dict)
76
+ and "multi_options" in ui_options
77
+ and isinstance(ui_options["multi_options"], dict)
78
+ and "choices" in ui_options["multi_options"]
79
+ ):
80
+ # Use live ui_options data (this survives serialization)
81
+ return ui_options["multi_options"]["choices"]
82
+
83
+ # Fallback to internal field (used during initialization or if ui_options missing)
84
+ return self._choices
85
+
86
+ @choices.setter
87
+ def choices(self, value: list) -> None:
88
+ """Set multi-options choices with dual synchronization.
89
+
90
+ CRITICAL: This setter writes to BOTH locations to maintain consistency:
91
+ 1. _choices field (for fallback and ui_options_for_trait())
92
+ 2. ui_options["multi_options"]["choices"] (for serialization and runtime use)
93
+
94
+ This dual sync ensures:
95
+ - Immediate runtime consistency
96
+ - Proper serialization of choices data
97
+ - Fallback safety if either location fails
98
+ """
99
+ # Always update internal field first (provides fallback safety)
100
+ self._choices = value
101
+
102
+ # Sync to ui_options if we have a parent parameter (normal case after trait attachment)
103
+ if self._parent and hasattr(self._parent, "ui_options"):
104
+ ui_options = getattr(self._parent, "ui_options", None)
105
+ if not isinstance(ui_options, dict):
106
+ # Initialize ui_options if it doesn't exist or isn't a dict
107
+ self._parent.ui_options = {} # type: ignore[attr-defined]
108
+
109
+ # Ensure multi_options exists and is a dict
110
+ if "multi_options" not in self._parent.ui_options or not isinstance( # type: ignore[attr-defined]
111
+ self._parent.ui_options["multi_options"], # type: ignore[attr-defined]
112
+ dict,
113
+ ):
114
+ self._parent.ui_options["multi_options"] = {} # type: ignore[attr-defined]
115
+
116
+ # Write choices to ui_options (this gets serialized and survives reload)
117
+ self._parent.ui_options["multi_options"]["choices"] = value # type: ignore[attr-defined]
118
+
119
+ @classmethod
120
+ def get_trait_keys(cls) -> list[str]:
121
+ return ["multi_options"]
122
+
123
+ def converters_for_trait(self) -> list[Callable]:
124
+ def converter(value: Any) -> Any:
125
+ # CRITICAL: This converter uses self.choices property (not _choices field)
126
+ # The property reads from ui_options first, ensuring we use post-deserialization
127
+ # choices data instead of stale trait field data.
128
+
129
+ # Handle case where value is not a list (convert single values to list)
130
+ if not isinstance(value, list):
131
+ if value is None:
132
+ return []
133
+ value = [value]
134
+
135
+ # Filter out invalid choices and return valid ones
136
+ valid_choices = [v for v in value if v in self.choices]
137
+
138
+ # If no valid choices, return empty list (allow empty selection)
139
+ return valid_choices
140
+
141
+ return [converter]
142
+
143
+ def validators_for_trait(self) -> list[Callable[[Parameter, Any], Any]]:
144
+ def validator(param: Parameter, value: Any) -> None: # noqa: ARG001
145
+ # CRITICAL: This validator uses self.choices property (not _choices field)
146
+ # Same reasoning as converter - use live ui_options data after deserialization
147
+
148
+ # Allow None or empty list as valid (no selection)
149
+ if value is None or value == []:
150
+ return
151
+
152
+ # Ensure value is a list
153
+ if not isinstance(value, list):
154
+ msg = "MultiOptions value must be a list"
155
+ raise TypeError(msg)
156
+
157
+ # Check that all selected values are valid choices
158
+ invalid_choices = [v for v in value if v not in self.choices]
159
+ if invalid_choices:
160
+ msg = f"Invalid choices: {invalid_choices}"
161
+ raise ValueError(msg)
162
+
163
+ return [validator]
164
+
165
+ def ui_options_for_trait(self) -> dict:
166
+ """Provide UI options for trait initialization.
167
+
168
+ IMPORTANT: This method uses _choices (not self.choices property) to avoid
169
+ circular dependency during Parameter.ui_options property construction:
170
+
171
+ Circular dependency would be:
172
+ 1. Parameter.ui_options calls trait.ui_options_for_trait()
173
+ 2. ui_options_for_trait() calls self.choices property
174
+ 3. choices property tries to read parent.ui_options
175
+ 4. This triggers Parameter.ui_options again → infinite recursion
176
+
177
+ Using _choices directly breaks this cycle while still providing the correct
178
+ initial choices for UI rendering. The property-based sync handles runtime updates.
179
+ """
180
+ return {
181
+ "multi_options": {
182
+ "choices": self._choices,
183
+ "placeholder": self.placeholder,
184
+ "max_selected_display": self.max_selected_display,
185
+ "show_search": self.show_search,
186
+ "icon_size": self.icon_size,
187
+ }
188
+ }
@@ -0,0 +1,77 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+ from griptape_nodes.exe_types.core_types import Parameter, ParameterMode, Trait
6
+
7
+
8
+ @dataclass(eq=False)
9
+ class NumbersSelector(Trait):
10
+ defaults: dict[str, float] = field(kw_only=True)
11
+ step: float = 1.0
12
+ overall_min: float | None = None
13
+ overall_max: float | None = None
14
+ element_id: str = field(default_factory=lambda: "NumbersSelector")
15
+
16
+ _allowed_modes: set = field(default_factory=lambda: {ParameterMode.PROPERTY})
17
+
18
+ def __init__(
19
+ self,
20
+ defaults: dict[str, float],
21
+ step: float = 1.0,
22
+ overall_min: float | None = None,
23
+ overall_max: float | None = None,
24
+ ) -> None:
25
+ super().__init__()
26
+ self.defaults = defaults
27
+ self.step = step
28
+ self.overall_min = overall_min
29
+ self.overall_max = overall_max
30
+
31
+ @classmethod
32
+ def get_trait_keys(cls) -> list[str]:
33
+ return ["numbers_selector"]
34
+
35
+ def ui_options_for_trait(self) -> dict:
36
+ return {
37
+ "numbers_selector": {
38
+ "step": self.step,
39
+ "overall_min": self.overall_min,
40
+ "overall_max": self.overall_max,
41
+ "defaults": self.defaults,
42
+ }
43
+ }
44
+
45
+ def display_options_for_trait(self) -> dict:
46
+ return {}
47
+
48
+ def converters_for_trait(self) -> list[Callable]:
49
+ return []
50
+
51
+ def validators_for_trait(self) -> list[Callable[..., Any]]:
52
+ def validate(_param: Parameter, value: Any) -> None:
53
+ if value is None:
54
+ return
55
+
56
+ if not isinstance(value, dict):
57
+ msg = "NumbersSelector value must be a dictionary"
58
+ raise TypeError(msg)
59
+
60
+ for key, val in value.items():
61
+ if not isinstance(key, str):
62
+ msg = f"NumbersSelector keys must be strings, got {type(key)}"
63
+ raise TypeError(msg)
64
+
65
+ if not isinstance(val, (int, float)):
66
+ msg = f"NumbersSelector values must be numbers, got {type(val)} for key '{key}'"
67
+ raise TypeError(msg)
68
+
69
+ if self.overall_min is not None and val < self.overall_min:
70
+ msg = f"Value {val} for key '{key}' is below minimum {self.overall_min}"
71
+ raise ValueError(msg)
72
+
73
+ if self.overall_max is not None and val > self.overall_max:
74
+ msg = f"Value {val} for key '{key}' is above maximum {self.overall_max}"
75
+ raise ValueError(msg)
76
+
77
+ return [validate]
@@ -7,15 +7,90 @@ from griptape_nodes.exe_types.core_types import Parameter, Trait
7
7
 
8
8
  @dataclass(eq=False)
9
9
  class Options(Trait):
10
- choices: list[str] = field(default_factory=lambda: ["choice 1", "choice 2", "choice 3"])
10
+ # SERIALIZATION BUG FIX EXPLANATION:
11
+ #
12
+ # PROBLEM: Options trait had a serialization bug where dynamically populated dropdown
13
+ # lists would work correctly during runtime but revert to the first choice after
14
+ # save/reload cycles. This happened because:
15
+ # 1. trait.choices was the "source of truth" during runtime
16
+ # 2. ui_options["simple_dropdown"] was populated from trait.choices
17
+ # 3. Only ui_options gets serialized/deserialized (not trait fields)
18
+ # 4. After reload, trait.choices was stale but ui_options had correct data
19
+ # 5. Converters used stale trait.choices, causing values to revert to first choice
20
+ #
21
+ # SOLUTION: Make ui_options the primary source of truth, with _choices as fallback
22
+ # 1. choices property reads from ui_options["simple_dropdown"] when available
23
+ # 2. choices setter writes to BOTH _choices and ui_options (dual sync)
24
+ # 3. This ensures serialized ui_options data is used after deserialization
25
+ # 4. _choices provides safety fallback if ui_options is missing/corrupted
26
+
27
+ _choices: list = field(default_factory=lambda: ["choice 1", "choice 2", "choice 3"])
11
28
  element_id: str = field(default_factory=lambda: "Options")
12
29
 
30
+ def __init__(self, *, choices: list | None = None) -> None:
31
+ super().__init__()
32
+ # Set choices through property to ensure dual sync from the start
33
+ if choices is not None:
34
+ self.choices = choices
35
+
36
+ @property
37
+ def choices(self) -> list:
38
+ """Get dropdown choices with ui_options as primary source of truth.
39
+
40
+ CRITICAL: This property prioritizes ui_options["simple_dropdown"] over _choices
41
+ because ui_options gets properly serialized/deserialized while trait fields don't.
42
+
43
+ Read priority:
44
+ 1. FIRST: ui_options["simple_dropdown"] (survives serialization cycles)
45
+ 2. FALLBACK: _choices field (safety net for edge cases)
46
+
47
+ This fixes the bug where selected values reverted to first choice after reload.
48
+ """
49
+ # Check if we have a parent parameter with ui_options (normal case after trait attachment)
50
+ if self._parent and hasattr(self._parent, "ui_options"):
51
+ ui_options = getattr(self._parent, "ui_options", None)
52
+ if isinstance(ui_options, dict) and "simple_dropdown" in ui_options:
53
+ # Use live ui_options data (this survives serialization)
54
+ return ui_options["simple_dropdown"]
55
+
56
+ # Fallback to internal field (used during initialization or if ui_options missing)
57
+ return self._choices
58
+
59
+ @choices.setter
60
+ def choices(self, value: list) -> None:
61
+ """Set dropdown choices with dual synchronization.
62
+
63
+ CRITICAL: This setter writes to BOTH locations to maintain consistency:
64
+ 1. _choices field (for fallback and ui_options_for_trait())
65
+ 2. ui_options["simple_dropdown"] (for serialization and runtime use)
66
+
67
+ This dual sync ensures:
68
+ - Immediate runtime consistency
69
+ - Proper serialization of choices data
70
+ - Fallback safety if either location fails
71
+ """
72
+ # Always update internal field first (provides fallback safety)
73
+ self._choices = value
74
+
75
+ # Sync to ui_options if we have a parent parameter (normal case after trait attachment)
76
+ if self._parent and hasattr(self._parent, "ui_options"):
77
+ ui_options = getattr(self._parent, "ui_options", None)
78
+ if not isinstance(ui_options, dict):
79
+ # Initialize ui_options if it doesn't exist or isn't a dict
80
+ self._parent.ui_options = {} # type: ignore[attr-defined]
81
+ # Write choices to ui_options (this gets serialized and survives reload)
82
+ self._parent.ui_options["simple_dropdown"] = value # type: ignore[attr-defined]
83
+
13
84
  @classmethod
14
85
  def get_trait_keys(cls) -> list[str]:
15
86
  return ["options", "models"]
16
87
 
17
88
  def converters_for_trait(self) -> list[Callable]:
18
89
  def converter(value: Any) -> Any:
90
+ # CRITICAL: This converter uses self.choices property (not _choices field)
91
+ # The property reads from ui_options first, ensuring we use post-deserialization
92
+ # choices data instead of stale trait field data. This prevents the bug where
93
+ # selected values revert to first choice after save/reload.
19
94
  if value not in self.choices:
20
95
  return self.choices[0]
21
96
  return value
@@ -24,6 +99,8 @@ class Options(Trait):
24
99
 
25
100
  def validators_for_trait(self) -> list[Callable[[Parameter, Any], Any]]:
26
101
  def validator(param: Parameter, value: Any) -> None: # noqa: ARG001
102
+ # CRITICAL: This validator uses self.choices property (not _choices field)
103
+ # Same reasoning as converter - use live ui_options data after deserialization
27
104
  if value not in self.choices:
28
105
  msg = "Choice not allowed"
29
106
  raise ValueError(msg)
@@ -31,4 +108,18 @@ class Options(Trait):
31
108
  return [validator]
32
109
 
33
110
  def ui_options_for_trait(self) -> dict:
34
- return {"simple_dropdown": self.choices}
111
+ """Provide UI options for trait initialization.
112
+
113
+ IMPORTANT: This method uses _choices (not self.choices property) to avoid
114
+ circular dependency during Parameter.ui_options property construction:
115
+
116
+ Circular dependency would be:
117
+ 1. Parameter.ui_options calls trait.ui_options_for_trait()
118
+ 2. ui_options_for_trait() calls self.choices property
119
+ 3. choices property tries to read parent.ui_options
120
+ 4. This triggers Parameter.ui_options again → infinite recursion
121
+
122
+ Using _choices directly breaks this cycle while still providing the correct
123
+ initial choices for UI rendering. The property-based sync handles runtime updates.
124
+ """
125
+ return {"simple_dropdown": self._choices}
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import inspect
7
+ import logging
7
8
  import subprocess
8
9
  from typing import TYPE_CHECKING, Any
9
10
 
@@ -11,6 +12,9 @@ if TYPE_CHECKING:
11
12
  from collections.abc import Callable, Sequence
12
13
 
13
14
 
15
+ logger = logging.getLogger(__name__)
16
+
17
+
14
18
  async def call_function(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
15
19
  """Call a function, handling both sync and async cases.
16
20
 
@@ -87,3 +91,30 @@ async def subprocess_run(
87
91
  )
88
92
 
89
93
  return completed_process
94
+
95
+
96
+ async def cancel_subprocess(process: asyncio.subprocess.Process, name: str = "process") -> None:
97
+ """Cancel a subprocess with graceful termination then force kill.
98
+
99
+ Args:
100
+ process: The subprocess to cancel
101
+ name: Name/description for logging purposes
102
+ """
103
+ if process.returncode is not None: # Process already terminated
104
+ return
105
+
106
+ try:
107
+ process.terminate()
108
+ logger.info("Terminated %s", name)
109
+
110
+ # Give process a chance to terminate gracefully
111
+ try:
112
+ await asyncio.wait_for(process.wait(), timeout=5.0)
113
+ except TimeoutError:
114
+ # Force kill if it doesn't terminate
115
+ process.kill()
116
+ logger.info("Force killed %s", name)
117
+ await process.wait()
118
+ except ProcessLookupError:
119
+ # Process already terminated
120
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: griptape-nodes
3
- Version: 0.53.0
3
+ Version: 0.54.1
4
4
  Summary: Add your description here
5
5
  Requires-Dist: griptape>=1.8.2
6
6
  Requires-Dist: pydantic>=2.10.6
@@ -20,6 +20,8 @@ Requires-Dist: binaryornot>=0.4.4
20
20
  Requires-Dist: pillow>=11.3.0
21
21
  Requires-Dist: watchfiles>=1.1.0
22
22
  Requires-Dist: typer>=0.15.0
23
+ Requires-Dist: huggingface-hub>=0.28.0
24
+ Requires-Dist: rich>=14.1.0
23
25
  Requires-Dist: austin-dist>=3.7.0 ; extra == 'profiling'
24
26
  Requires-Python: >=3.12.0, <3.13
25
27
  Provides-Extra: profiling