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.
- griptape_nodes/__init__.py +5 -2
- griptape_nodes/app/app.py +4 -26
- griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +35 -5
- griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +15 -1
- griptape_nodes/cli/commands/config.py +4 -1
- griptape_nodes/cli/commands/init.py +5 -3
- griptape_nodes/cli/commands/libraries.py +14 -8
- griptape_nodes/cli/commands/models.py +504 -0
- griptape_nodes/cli/commands/self.py +5 -2
- griptape_nodes/cli/main.py +11 -1
- griptape_nodes/cli/shared.py +0 -9
- griptape_nodes/common/directed_graph.py +17 -1
- griptape_nodes/drivers/storage/base_storage_driver.py +40 -20
- griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +24 -29
- griptape_nodes/drivers/storage/local_storage_driver.py +17 -13
- griptape_nodes/exe_types/node_types.py +219 -14
- griptape_nodes/exe_types/param_components/__init__.py +1 -0
- griptape_nodes/exe_types/param_components/execution_status_component.py +138 -0
- griptape_nodes/machines/control_flow.py +129 -92
- griptape_nodes/machines/dag_builder.py +207 -0
- griptape_nodes/machines/parallel_resolution.py +264 -276
- griptape_nodes/machines/sequential_resolution.py +9 -7
- griptape_nodes/node_library/library_registry.py +34 -1
- griptape_nodes/retained_mode/events/app_events.py +5 -1
- griptape_nodes/retained_mode/events/base_events.py +7 -7
- griptape_nodes/retained_mode/events/config_events.py +30 -0
- griptape_nodes/retained_mode/events/execution_events.py +2 -2
- griptape_nodes/retained_mode/events/model_events.py +296 -0
- griptape_nodes/retained_mode/griptape_nodes.py +10 -1
- griptape_nodes/retained_mode/managers/agent_manager.py +14 -0
- griptape_nodes/retained_mode/managers/config_manager.py +44 -3
- griptape_nodes/retained_mode/managers/event_manager.py +8 -2
- griptape_nodes/retained_mode/managers/flow_manager.py +45 -14
- griptape_nodes/retained_mode/managers/library_manager.py +3 -3
- griptape_nodes/retained_mode/managers/model_manager.py +1107 -0
- griptape_nodes/retained_mode/managers/node_manager.py +26 -26
- griptape_nodes/retained_mode/managers/object_manager.py +1 -1
- griptape_nodes/retained_mode/managers/os_manager.py +6 -6
- griptape_nodes/retained_mode/managers/settings.py +87 -9
- griptape_nodes/retained_mode/managers/static_files_manager.py +77 -9
- griptape_nodes/retained_mode/managers/sync_manager.py +10 -5
- griptape_nodes/retained_mode/managers/workflow_manager.py +101 -92
- griptape_nodes/retained_mode/retained_mode.py +19 -0
- griptape_nodes/servers/__init__.py +1 -0
- griptape_nodes/{mcp_server/server.py → servers/mcp.py} +1 -1
- griptape_nodes/{app/api.py → servers/static.py} +43 -40
- griptape_nodes/traits/button.py +124 -6
- griptape_nodes/traits/multi_options.py +188 -0
- griptape_nodes/traits/numbers_selector.py +77 -0
- griptape_nodes/traits/options.py +93 -2
- griptape_nodes/utils/async_utils.py +31 -0
- {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/METADATA +3 -1
- {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/RECORD +56 -47
- {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/WHEEL +1 -1
- /griptape_nodes/{mcp_server → servers}/ws_request_manager.py +0 -0
- {griptape_nodes-0.53.0.dist-info → griptape_nodes-0.54.1.dist-info}/entry_points.txt +0 -0
griptape_nodes/traits/button.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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]
|
griptape_nodes/traits/options.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|