griptape-nodes 0.37.1__py3-none-any.whl → 0.38.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- griptape_nodes/__init__.py +292 -132
- griptape_nodes/app/__init__.py +1 -6
- griptape_nodes/app/app.py +108 -76
- griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +80 -5
- griptape_nodes/drivers/storage/local_storage_driver.py +5 -1
- griptape_nodes/exe_types/core_types.py +84 -3
- griptape_nodes/exe_types/node_types.py +260 -50
- griptape_nodes/machines/node_resolution.py +2 -14
- griptape_nodes/retained_mode/events/agent_events.py +7 -0
- griptape_nodes/retained_mode/events/base_events.py +16 -0
- griptape_nodes/retained_mode/events/library_events.py +26 -0
- griptape_nodes/retained_mode/events/parameter_events.py +31 -0
- griptape_nodes/retained_mode/griptape_nodes.py +32 -0
- griptape_nodes/retained_mode/managers/agent_manager.py +25 -12
- griptape_nodes/retained_mode/managers/config_manager.py +37 -4
- griptape_nodes/retained_mode/managers/event_manager.py +15 -0
- griptape_nodes/retained_mode/managers/flow_manager.py +64 -61
- griptape_nodes/retained_mode/managers/library_manager.py +215 -45
- griptape_nodes/retained_mode/managers/node_manager.py +344 -147
- griptape_nodes/retained_mode/managers/operation_manager.py +6 -0
- griptape_nodes/retained_mode/managers/os_manager.py +6 -1
- griptape_nodes/retained_mode/managers/secrets_manager.py +7 -2
- griptape_nodes/retained_mode/managers/settings.py +2 -11
- griptape_nodes/retained_mode/managers/static_files_manager.py +12 -3
- griptape_nodes/retained_mode/managers/version_compatibility_manager.py +105 -0
- griptape_nodes/retained_mode/managers/workflow_manager.py +4 -4
- griptape_nodes/updater/__init__.py +14 -8
- griptape_nodes/version_compatibility/__init__.py +1 -0
- griptape_nodes/version_compatibility/versions/__init__.py +1 -0
- griptape_nodes/version_compatibility/versions/v0_39_0/__init__.py +1 -0
- griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +77 -0
- {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/METADATA +4 -1
- {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/RECORD +36 -33
- griptape_nodes/app/app_websocket.py +0 -481
- griptape_nodes/app/nodes_api_socket_manager.py +0 -117
- {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/WHEEL +0 -0
- {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/entry_points.txt +0 -0
- {griptape_nodes-0.37.1.dist-info → griptape_nodes-0.38.0.dist-info}/licenses/LICENSE +0 -0
griptape_nodes/app/app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import binascii
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
@@ -9,11 +10,9 @@ import sys
|
|
|
9
10
|
import threading
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from queue import Queue
|
|
12
|
-
from time import sleep
|
|
13
13
|
from typing import Any, cast
|
|
14
14
|
from urllib.parse import urljoin
|
|
15
15
|
|
|
16
|
-
import httpx
|
|
17
16
|
import uvicorn
|
|
18
17
|
from dotenv import get_key
|
|
19
18
|
from fastapi import FastAPI, HTTPException, Request
|
|
@@ -27,10 +26,10 @@ from rich.align import Align
|
|
|
27
26
|
from rich.console import Console
|
|
28
27
|
from rich.logging import RichHandler
|
|
29
28
|
from rich.panel import Panel
|
|
29
|
+
from websockets.asyncio.client import connect
|
|
30
|
+
from websockets.exceptions import ConnectionClosed, WebSocketException
|
|
30
31
|
from xdg_base_dirs import xdg_config_home
|
|
31
32
|
|
|
32
|
-
from griptape_nodes.app.nodes_api_socket_manager import NodesApiSocketManager
|
|
33
|
-
|
|
34
33
|
# This import is necessary to register all events, even if not technically used
|
|
35
34
|
from griptape_nodes.retained_mode.events import app_events, execution_events
|
|
36
35
|
from griptape_nodes.retained_mode.events.base_events import (
|
|
@@ -50,6 +49,10 @@ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
|
|
|
50
49
|
# This is a global event queue that will be used to pass events between threads
|
|
51
50
|
event_queue = Queue()
|
|
52
51
|
|
|
52
|
+
# Global WebSocket connection for sending events
|
|
53
|
+
ws_connection_for_sending = None
|
|
54
|
+
event_loop = None
|
|
55
|
+
|
|
53
56
|
# Whether to enable the static server
|
|
54
57
|
STATIC_SERVER_ENABLED = os.getenv("STATIC_SERVER_ENABLED", "true").lower() == "true"
|
|
55
58
|
# Host of the static server
|
|
@@ -77,7 +80,7 @@ class EventLogHandler(logging.Handler):
|
|
|
77
80
|
|
|
78
81
|
|
|
79
82
|
# Logger for this module. Important that this is not the same as the griptape_nodes logger or else we'll have infinite log events.
|
|
80
|
-
logger = logging.getLogger(
|
|
83
|
+
logger = logging.getLogger("griptape_nodes_app")
|
|
81
84
|
console = Console()
|
|
82
85
|
|
|
83
86
|
|
|
@@ -86,7 +89,7 @@ def start_app() -> None:
|
|
|
86
89
|
|
|
87
90
|
Starts the event loop and listens for events from the Nodes API.
|
|
88
91
|
"""
|
|
89
|
-
|
|
92
|
+
_init_event_listeners()
|
|
90
93
|
|
|
91
94
|
griptape_nodes_logger = logging.getLogger("griptape_nodes")
|
|
92
95
|
# When running as an app, we want to forward all log messages to the event queue so they can be sent to the GUI
|
|
@@ -94,11 +97,6 @@ def start_app() -> None:
|
|
|
94
97
|
griptape_nodes_logger.addHandler(RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True))
|
|
95
98
|
griptape_nodes_logger.setLevel(logging.INFO)
|
|
96
99
|
|
|
97
|
-
# Listen for SSE events from the Nodes API in a separate thread
|
|
98
|
-
socket_manager = NodesApiSocketManager()
|
|
99
|
-
|
|
100
|
-
_init_event_listeners()
|
|
101
|
-
|
|
102
100
|
# Listen for any signals to exit the app
|
|
103
101
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
104
102
|
signal.signal(sig, lambda *_: sys.exit(0))
|
|
@@ -220,45 +218,50 @@ def _init_event_listeners() -> None:
|
|
|
220
218
|
)
|
|
221
219
|
|
|
222
220
|
|
|
223
|
-
def
|
|
224
|
-
"""Listen for events from the Nodes API and process them."""
|
|
225
|
-
|
|
226
|
-
|
|
221
|
+
async def _alisten_for_api_requests() -> None:
|
|
222
|
+
"""Listen for events from the Nodes API and process them asynchronously."""
|
|
223
|
+
global ws_connection_for_sending, event_loop # noqa: PLW0603
|
|
224
|
+
event_loop = asyncio.get_running_loop() # Store the event loop reference
|
|
227
225
|
nodes_app_url = os.getenv("GRIPTAPE_NODES_UI_BASE_URL", "https://nodes.griptape.ai")
|
|
228
|
-
logger.info("Listening for events from Nodes API
|
|
229
|
-
|
|
226
|
+
logger.info("Listening for events from Nodes API via async WebSocket")
|
|
227
|
+
|
|
228
|
+
# Auto reconnect https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html#opening-a-connection
|
|
229
|
+
connection_stream = __create_async_websocket_connection()
|
|
230
|
+
initialized = False
|
|
231
|
+
async for ws_connection in connection_stream:
|
|
230
232
|
try:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
logger.exception("Error processing event, skipping.")
|
|
256
|
-
|
|
257
|
-
except httpx.RemoteProtocolError as e:
|
|
258
|
-
logger.debug("Server closed connection, this is expected. Reconnecting... %s", e)
|
|
233
|
+
ws_connection_for_sending = ws_connection # Store for sending events
|
|
234
|
+
if not initialized:
|
|
235
|
+
__broadcast_app_initialization_complete(nodes_app_url)
|
|
236
|
+
initialized = True
|
|
237
|
+
|
|
238
|
+
async for message in ws_connection:
|
|
239
|
+
try:
|
|
240
|
+
data = json.loads(message)
|
|
241
|
+
|
|
242
|
+
payload = data.get("payload", {})
|
|
243
|
+
# With heartbeat events, we skip the regular processing and just send the heartbeat
|
|
244
|
+
# Technically no longer needed since https://github.com/griptape-ai/griptape-nodes/pull/369
|
|
245
|
+
# but we don't have a proper EventRequest for it yet.
|
|
246
|
+
if payload.get("request_type") == "Heartbeat":
|
|
247
|
+
session_id = GriptapeNodes.get_session_id()
|
|
248
|
+
await __send_heartbeat(
|
|
249
|
+
session_id=session_id, request=payload["request"], ws_connection=ws_connection
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
__process_api_event(payload)
|
|
253
|
+
except Exception:
|
|
254
|
+
logger.exception("Error processing event, skipping.")
|
|
255
|
+
except ConnectionClosed:
|
|
256
|
+
continue
|
|
259
257
|
except Exception as e:
|
|
260
258
|
logger.error("Error while listening for events. Retrying in 2 seconds... %s", e)
|
|
261
|
-
sleep(2)
|
|
259
|
+
await asyncio.sleep(2)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _listen_for_api_events() -> None:
|
|
263
|
+
"""Run the async WebSocket listener in an event loop."""
|
|
264
|
+
asyncio.run(_alisten_for_api_requests())
|
|
262
265
|
|
|
263
266
|
|
|
264
267
|
def __process_node_event(event: GriptapeNodeEvent) -> None:
|
|
@@ -272,10 +275,9 @@ def __process_node_event(event: GriptapeNodeEvent) -> None:
|
|
|
272
275
|
else:
|
|
273
276
|
msg = f"Unknown/unsupported result event type encountered: '{type(result_event)}'."
|
|
274
277
|
raise TypeError(msg) from None
|
|
275
|
-
|
|
276
278
|
# Don't send events over the wire that don't have a request_id set (e.g. engine-internal events)
|
|
277
279
|
event_json = result_event.json()
|
|
278
|
-
|
|
280
|
+
__schedule_async_task(__emit_message(dest_socket, event_json))
|
|
279
281
|
|
|
280
282
|
|
|
281
283
|
def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
|
|
@@ -297,8 +299,7 @@ def __process_execution_node_event(event: ExecutionGriptapeNodeEvent) -> None:
|
|
|
297
299
|
msg = "Node start and finish do not match."
|
|
298
300
|
raise KeyError(msg) from None
|
|
299
301
|
GriptapeNodes.EventManager().current_active_node = None
|
|
300
|
-
|
|
301
|
-
socket_manager.emit("execution_event", event_json)
|
|
302
|
+
__schedule_async_task(__emit_message("execution_event", event_json))
|
|
302
303
|
|
|
303
304
|
|
|
304
305
|
def __process_progress_event(gt_event: ProgressEvent) -> None:
|
|
@@ -310,7 +311,7 @@ def __process_progress_event(gt_event: ProgressEvent) -> None:
|
|
|
310
311
|
node_name=node_name, parameter_name=gt_event.parameter_name, type=type(gt_event).__name__, value=value
|
|
311
312
|
)
|
|
312
313
|
event_to_emit = ExecutionEvent(payload=payload)
|
|
313
|
-
|
|
314
|
+
__schedule_async_task(__emit_message("execution_event", event_to_emit.json()))
|
|
314
315
|
|
|
315
316
|
|
|
316
317
|
def __process_app_event(event: AppEvent) -> None:
|
|
@@ -318,7 +319,7 @@ def __process_app_event(event: AppEvent) -> None:
|
|
|
318
319
|
# Let Griptape Nodes broadcast it.
|
|
319
320
|
GriptapeNodes.broadcast_app_event(event.payload)
|
|
320
321
|
|
|
321
|
-
|
|
322
|
+
__schedule_async_task(__emit_message("app_event", event.json()))
|
|
322
323
|
|
|
323
324
|
|
|
324
325
|
def _process_event_queue() -> None:
|
|
@@ -339,7 +340,8 @@ def _process_event_queue() -> None:
|
|
|
339
340
|
event_queue.task_done()
|
|
340
341
|
|
|
341
342
|
|
|
342
|
-
def
|
|
343
|
+
def __create_async_websocket_connection() -> Any:
|
|
344
|
+
"""Create an async WebSocket connection to the Nodes API."""
|
|
343
345
|
api_key = get_key(xdg_config_home() / "griptape_nodes" / ".env", "GT_CLOUD_API_KEY")
|
|
344
346
|
if api_key is None:
|
|
345
347
|
message = Panel(
|
|
@@ -354,33 +356,63 @@ def __build_authorized_request(request: httpx.Request) -> httpx.Request:
|
|
|
354
356
|
)
|
|
355
357
|
console.print(message)
|
|
356
358
|
sys.exit(1)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
359
|
+
|
|
360
|
+
endpoint = urljoin(
|
|
361
|
+
os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
|
|
362
|
+
"/ws/engines/events?publish_channel=responses&subscribe_channel=requests",
|
|
362
363
|
)
|
|
363
|
-
return request
|
|
364
364
|
|
|
365
|
+
return connect(
|
|
366
|
+
endpoint,
|
|
367
|
+
additional_headers={"Authorization": f"Bearer {api_key}"},
|
|
368
|
+
)
|
|
365
369
|
|
|
366
|
-
def __check_api_key_validity(response: httpx.Response) -> None:
|
|
367
|
-
"""Check if the API key is valid by checking the response status code.
|
|
368
370
|
|
|
369
|
-
|
|
370
|
-
"""
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
371
|
+
async def __emit_message(event_type: str, payload: str) -> None:
|
|
372
|
+
"""Send a message via WebSocket asynchronously."""
|
|
373
|
+
global ws_connection_for_sending # noqa: PLW0602
|
|
374
|
+
if ws_connection_for_sending is None:
|
|
375
|
+
logger.warning("WebSocket connection not available for sending message")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
body = {"type": event_type, "payload": json.loads(payload) if payload else {}}
|
|
380
|
+
await ws_connection_for_sending.send(json.dumps(body))
|
|
381
|
+
except WebSocketException as e:
|
|
382
|
+
logger.error("Error sending event to Nodes API: %s", e)
|
|
383
|
+
except Exception as e:
|
|
384
|
+
logger.error("Unexpected error while sending event to Nodes API: %s", e)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def __send_heartbeat(*, session_id: str | None, request: dict, ws_connection: Any) -> None:
|
|
388
|
+
"""Send a heartbeat response via WebSocket."""
|
|
389
|
+
heartbeat_response = {
|
|
390
|
+
"request": request,
|
|
391
|
+
"result": {},
|
|
392
|
+
"request_type": "Heartbeat",
|
|
393
|
+
"event_type": "EventResultSuccess",
|
|
394
|
+
"result_type": "HeartbeatSuccess",
|
|
395
|
+
**({"session_id": session_id} if session_id is not None else {}),
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
body = {"type": "success_result", "payload": heartbeat_response}
|
|
399
|
+
try:
|
|
400
|
+
await ws_connection.send(json.dumps(body))
|
|
401
|
+
logger.debug(
|
|
402
|
+
"Responded to heartbeat request with session: %s and request: %s", session_id, request.get("request_id")
|
|
381
403
|
)
|
|
382
|
-
|
|
383
|
-
|
|
404
|
+
except WebSocketException as e:
|
|
405
|
+
logger.error("Error sending heartbeat response: %s", e)
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.error("Unexpected error while sending heartbeat response: %s", e)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def __schedule_async_task(coro: Any) -> None:
|
|
411
|
+
"""Schedule an async coroutine to run in the event loop from a sync context."""
|
|
412
|
+
if event_loop and event_loop.is_running():
|
|
413
|
+
asyncio.run_coroutine_threadsafe(coro, event_loop)
|
|
414
|
+
else:
|
|
415
|
+
logger.warning("Event loop not available for scheduling async task")
|
|
384
416
|
|
|
385
417
|
|
|
386
418
|
def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
|
|
@@ -414,7 +446,7 @@ def __broadcast_app_initialization_complete(nodes_app_url: str) -> None:
|
|
|
414
446
|
console.print(message)
|
|
415
447
|
|
|
416
448
|
|
|
417
|
-
def __process_api_event(data:
|
|
449
|
+
def __process_api_event(data: dict) -> None:
|
|
418
450
|
"""Process API events and send them to the event queue."""
|
|
419
451
|
try:
|
|
420
452
|
data["request"]
|
|
@@ -15,18 +15,20 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
15
15
|
def __init__(
|
|
16
16
|
self,
|
|
17
17
|
*,
|
|
18
|
-
bucket_id: str
|
|
18
|
+
bucket_id: str,
|
|
19
19
|
base_url: str | None = None,
|
|
20
20
|
api_key: str | None = None,
|
|
21
21
|
headers: dict | None = None,
|
|
22
|
+
static_files_directory: str | None = None,
|
|
22
23
|
) -> None:
|
|
23
24
|
"""Initialize the GriptapeCloudStorageDriver.
|
|
24
25
|
|
|
25
26
|
Args:
|
|
26
|
-
bucket_id: The ID of the bucket to use.
|
|
27
|
+
bucket_id: The ID of the bucket to use. Required.
|
|
27
28
|
base_url: The base URL for the Griptape Cloud API. If not provided, it will be retrieved from the environment variable "GT_CLOUD_BASE_URL" or default to "https://cloud.griptape.ai".
|
|
28
29
|
api_key: The API key for authentication. If not provided, it will be retrieved from the environment variable "GT_CLOUD_API_KEY".
|
|
29
30
|
headers: Additional headers to include in the requests. If not provided, the default headers will be used.
|
|
31
|
+
static_files_directory: The directory path prefix for static files. If provided, file names will be prefixed with this path.
|
|
30
32
|
"""
|
|
31
33
|
self.base_url = (
|
|
32
34
|
base_url if base_url is not None else os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
|
|
@@ -41,11 +43,26 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
self.bucket_id = bucket_id
|
|
46
|
+
self.static_files_directory = static_files_directory
|
|
47
|
+
|
|
48
|
+
def _get_full_file_path(self, file_name: str) -> str:
|
|
49
|
+
"""Get the full file path including the static files directory prefix.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
file_name: The base file name.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The full file path with static files directory prefix if configured.
|
|
56
|
+
"""
|
|
57
|
+
if self.static_files_directory:
|
|
58
|
+
return f"{self.static_files_directory}/{file_name}"
|
|
59
|
+
return file_name
|
|
44
60
|
|
|
45
61
|
def create_signed_upload_url(self, file_name: str) -> CreateSignedUploadUrlResponse:
|
|
46
|
-
self.
|
|
62
|
+
full_file_path = self._get_full_file_path(file_name)
|
|
63
|
+
self._create_asset(full_file_path)
|
|
47
64
|
|
|
48
|
-
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{
|
|
65
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{full_file_path}")
|
|
49
66
|
try:
|
|
50
67
|
response = httpx.post(url, json={"operation": "PUT"}, headers=self.headers)
|
|
51
68
|
response.raise_for_status()
|
|
@@ -59,7 +76,8 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
59
76
|
return {"url": response_data["url"], "headers": response_data.get("headers", {}), "method": "PUT"}
|
|
60
77
|
|
|
61
78
|
def create_signed_download_url(self, file_name: str) -> str:
|
|
62
|
-
|
|
79
|
+
full_file_path = self._get_full_file_path(file_name)
|
|
80
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{full_file_path}")
|
|
63
81
|
try:
|
|
64
82
|
response = httpx.post(url, json={"method": "GET"}, headers=self.headers)
|
|
65
83
|
response.raise_for_status()
|
|
@@ -83,3 +101,60 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
83
101
|
raise ValueError(msg) from e
|
|
84
102
|
|
|
85
103
|
return response.json()["name"]
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def create_bucket(bucket_name: str, *, base_url: str, api_key: str) -> str:
|
|
107
|
+
"""Create a new bucket in Griptape Cloud.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
bucket_name: Name for the bucket.
|
|
111
|
+
base_url: The base URL for the Griptape Cloud API.
|
|
112
|
+
api_key: The API key for authentication.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The bucket ID of the created bucket.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
RuntimeError: If bucket creation fails.
|
|
119
|
+
"""
|
|
120
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
121
|
+
url = urljoin(base_url, "/api/buckets")
|
|
122
|
+
payload = {"name": bucket_name}
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
response = httpx.post(url, json=payload, headers=headers)
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
except httpx.HTTPStatusError as e:
|
|
128
|
+
msg = f"Failed to create bucket '{bucket_name}': {e}"
|
|
129
|
+
logger.error(msg)
|
|
130
|
+
raise RuntimeError(msg) from e
|
|
131
|
+
|
|
132
|
+
response_data = response.json()
|
|
133
|
+
bucket_id = response_data["bucket_id"]
|
|
134
|
+
|
|
135
|
+
logger.info("Created new Griptape Cloud bucket '%s' with ID: %s", bucket_name, bucket_id)
|
|
136
|
+
return bucket_id
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def list_buckets(*, base_url: str, api_key: str) -> list[dict]:
|
|
140
|
+
"""List all buckets in Griptape Cloud.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
base_url: The base URL for the Griptape Cloud API.
|
|
144
|
+
api_key: The API key for authentication.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A list of dictionaries containing bucket information.
|
|
148
|
+
"""
|
|
149
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
150
|
+
url = urljoin(base_url, "/api/buckets")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
response = httpx.get(url, headers=headers)
|
|
154
|
+
response.raise_for_status()
|
|
155
|
+
except httpx.HTTPStatusError as e:
|
|
156
|
+
msg = f"Failed to list buckets: {e}"
|
|
157
|
+
logger.error(msg)
|
|
158
|
+
raise RuntimeError(msg) from e
|
|
159
|
+
|
|
160
|
+
return response.json().get("buckets", [])
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import time
|
|
2
3
|
from urllib.parse import urljoin
|
|
3
4
|
|
|
4
5
|
import httpx
|
|
@@ -46,4 +47,7 @@ class LocalStorageDriver(BaseStorageDriver):
|
|
|
46
47
|
return {"url": url, "headers": response_data.get("headers", {}), "method": "PUT"}
|
|
47
48
|
|
|
48
49
|
def create_signed_download_url(self, file_name: str) -> str:
|
|
49
|
-
|
|
50
|
+
url = urljoin(self.base_url, f"/static/{file_name}")
|
|
51
|
+
# Add a cache-busting query parameter to the URL so that the browser always reloads the file
|
|
52
|
+
cache_busted_url = urljoin(url, f"?t={int(time.time())}")
|
|
53
|
+
return cache_busted_url
|
|
@@ -164,10 +164,12 @@ class BaseNodeElement:
|
|
|
164
164
|
element_type: str = field(default_factory=lambda: BaseNodeElement.__name__)
|
|
165
165
|
name: str = field(default_factory=lambda: str(f"{BaseNodeElement.__name__}_{uuid.uuid4().hex}"))
|
|
166
166
|
parent_group_name: str | None = None
|
|
167
|
+
_changes: dict[str, Any] = field(default_factory=dict)
|
|
167
168
|
|
|
168
169
|
_children: list[BaseNodeElement] = field(default_factory=list)
|
|
169
170
|
_stack: ClassVar[list[BaseNodeElement]] = []
|
|
170
171
|
_parent: BaseNodeElement | None = field(default=None)
|
|
172
|
+
_node_context: BaseNode | None = field(default=None)
|
|
171
173
|
|
|
172
174
|
@property
|
|
173
175
|
def children(self) -> list[BaseNodeElement]:
|
|
@@ -199,6 +201,59 @@ class BaseNodeElement:
|
|
|
199
201
|
def __repr__(self) -> str:
|
|
200
202
|
return f"BaseNodeElement({self.children=})"
|
|
201
203
|
|
|
204
|
+
def get_changes(self) -> dict[str, Any]:
|
|
205
|
+
return self._changes
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def emits_update_on_write(func: Callable) -> Callable:
|
|
209
|
+
"""Decorator for properties that should track changes and emit events."""
|
|
210
|
+
|
|
211
|
+
def wrapper(self: BaseNodeElement, *args, **kwargs) -> Callable:
|
|
212
|
+
# For setters, track the change
|
|
213
|
+
if len(args) >= 1: # setter with value
|
|
214
|
+
old_value = getattr(self, f"{func.__name__}", None) if hasattr(self, f"{func.__name__}") else None
|
|
215
|
+
result = func(self, *args, **kwargs)
|
|
216
|
+
new_value = getattr(self, f"{func.__name__}", None) if hasattr(self, f"{func.__name__}") else None
|
|
217
|
+
# Track change if different
|
|
218
|
+
if old_value != new_value:
|
|
219
|
+
# it needs to be static so we can call these methods.
|
|
220
|
+
self._changes[func.__name__] = new_value
|
|
221
|
+
if self._node_context is not None and self not in self._node_context._tracked_parameters:
|
|
222
|
+
self._node_context._tracked_parameters.append(self)
|
|
223
|
+
return result
|
|
224
|
+
return func(self, *args, **kwargs)
|
|
225
|
+
|
|
226
|
+
return wrapper
|
|
227
|
+
|
|
228
|
+
def _emit_alter_element_event_if_possible(self) -> None:
|
|
229
|
+
"""Emit an AlterElementEvent if we have node context and the necessary dependencies."""
|
|
230
|
+
if self._node_context is None:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
# Import here to avoid circular dependencies
|
|
234
|
+
from griptape.events import EventBus
|
|
235
|
+
|
|
236
|
+
from griptape_nodes.retained_mode.events.base_events import ExecutionEvent, ExecutionGriptapeNodeEvent
|
|
237
|
+
from griptape_nodes.retained_mode.events.parameter_events import AlterElementEvent
|
|
238
|
+
|
|
239
|
+
# Create base event data using the existing to_event method
|
|
240
|
+
# Create a modified event data that only includes changed fields
|
|
241
|
+
event_data = {
|
|
242
|
+
# Include base fields that should always be present
|
|
243
|
+
"element_id": self.element_id,
|
|
244
|
+
"element_type": self.element_type,
|
|
245
|
+
"name": self.name,
|
|
246
|
+
"node_name": self._node_context.name,
|
|
247
|
+
}
|
|
248
|
+
event_data.update(self._changes)
|
|
249
|
+
|
|
250
|
+
# Publish the event
|
|
251
|
+
event = ExecutionGriptapeNodeEvent(
|
|
252
|
+
wrapped_event=ExecutionEvent(payload=AlterElementEvent(element_details=event_data))
|
|
253
|
+
)
|
|
254
|
+
EventBus.publish_event(event)
|
|
255
|
+
self._changes.clear()
|
|
256
|
+
|
|
202
257
|
def to_dict(self) -> dict[str, Any]:
|
|
203
258
|
"""Returns a nested dictionary representation of this node and its children.
|
|
204
259
|
|
|
@@ -228,8 +283,18 @@ class BaseNodeElement:
|
|
|
228
283
|
if child._parent is not None:
|
|
229
284
|
child._parent.remove_child(child)
|
|
230
285
|
child._parent = self
|
|
286
|
+
# Propagate node context to children
|
|
287
|
+
child._node_context = self._node_context
|
|
231
288
|
self._children.append(child)
|
|
232
289
|
|
|
290
|
+
# Also propagate to any existing children of the child
|
|
291
|
+
for grandchild in child.find_elements_by_type(BaseNodeElement, find_recursively=True):
|
|
292
|
+
grandchild._node_context = self._node_context
|
|
293
|
+
|
|
294
|
+
# Emit event if we have node context
|
|
295
|
+
if self._node_context is not None:
|
|
296
|
+
self._node_context._emit_parameter_lifecycle_event(child)
|
|
297
|
+
|
|
233
298
|
def remove_child(self, child: BaseNodeElement | str) -> None:
|
|
234
299
|
ui_elements: list[BaseNodeElement] = [self]
|
|
235
300
|
for ui_element in ui_elements:
|
|
@@ -238,6 +303,8 @@ class BaseNodeElement:
|
|
|
238
303
|
ui_element._children.remove(child)
|
|
239
304
|
break
|
|
240
305
|
ui_elements.extend(ui_element._children)
|
|
306
|
+
if self._node_context is not None and isinstance(child, BaseNodeElement):
|
|
307
|
+
self._node_context._emit_parameter_lifecycle_event(child, remove=True)
|
|
241
308
|
|
|
242
309
|
def find_element_by_id(self, element_id: str) -> BaseNodeElement | None:
|
|
243
310
|
if self.element_id == element_id:
|
|
@@ -488,7 +555,7 @@ class Parameter(BaseNodeElement):
|
|
|
488
555
|
tooltip_as_output: str | list[dict] | None = None
|
|
489
556
|
settable: bool = True
|
|
490
557
|
user_defined: bool = False
|
|
491
|
-
|
|
558
|
+
_allowed_modes: set = field(
|
|
492
559
|
default_factory=lambda: {
|
|
493
560
|
ParameterMode.OUTPUT,
|
|
494
561
|
ParameterMode.INPUT,
|
|
@@ -539,9 +606,9 @@ class Parameter(BaseNodeElement):
|
|
|
539
606
|
self.settable = settable
|
|
540
607
|
self.user_defined = user_defined
|
|
541
608
|
if allowed_modes is None:
|
|
542
|
-
self.
|
|
609
|
+
self._allowed_modes = {ParameterMode.INPUT, ParameterMode.OUTPUT, ParameterMode.PROPERTY}
|
|
543
610
|
else:
|
|
544
|
-
self.
|
|
611
|
+
self._allowed_modes = allowed_modes
|
|
545
612
|
|
|
546
613
|
if converters is None:
|
|
547
614
|
self._converters = []
|
|
@@ -626,6 +693,7 @@ class Parameter(BaseNodeElement):
|
|
|
626
693
|
return ParameterTypeBuiltin.STR.value
|
|
627
694
|
|
|
628
695
|
@type.setter
|
|
696
|
+
@BaseNodeElement.emits_update_on_write
|
|
629
697
|
def type(self, value: str | None) -> None:
|
|
630
698
|
self._custom_setter_for_property_type(value)
|
|
631
699
|
|
|
@@ -659,6 +727,15 @@ class Parameter(BaseNodeElement):
|
|
|
659
727
|
validators += self._validators
|
|
660
728
|
return validators
|
|
661
729
|
|
|
730
|
+
@property
|
|
731
|
+
def allowed_modes(self) -> set[ParameterMode]:
|
|
732
|
+
return self._allowed_modes
|
|
733
|
+
|
|
734
|
+
@allowed_modes.setter
|
|
735
|
+
@BaseNodeElement.emits_update_on_write
|
|
736
|
+
def allowed_modes(self, value: Any) -> None:
|
|
737
|
+
self._allowed_modes = value
|
|
738
|
+
|
|
662
739
|
@property
|
|
663
740
|
def ui_options(self) -> dict:
|
|
664
741
|
ui_options = {}
|
|
@@ -671,6 +748,7 @@ class Parameter(BaseNodeElement):
|
|
|
671
748
|
return ui_options
|
|
672
749
|
|
|
673
750
|
@ui_options.setter
|
|
751
|
+
@BaseNodeElement.emits_update_on_write
|
|
674
752
|
def ui_options(self, value: dict) -> None:
|
|
675
753
|
self._ui_options = value
|
|
676
754
|
|
|
@@ -689,6 +767,7 @@ class Parameter(BaseNodeElement):
|
|
|
689
767
|
return [ParameterTypeBuiltin.STR.value]
|
|
690
768
|
|
|
691
769
|
@input_types.setter
|
|
770
|
+
@BaseNodeElement.emits_update_on_write
|
|
692
771
|
def input_types(self, value: list[str] | None) -> None:
|
|
693
772
|
self._custom_setter_for_property_input_types(value)
|
|
694
773
|
|
|
@@ -726,6 +805,7 @@ class Parameter(BaseNodeElement):
|
|
|
726
805
|
return ParameterTypeBuiltin.STR.value
|
|
727
806
|
|
|
728
807
|
@output_type.setter
|
|
808
|
+
@BaseNodeElement.emits_update_on_write
|
|
729
809
|
def output_type(self, value: str | None) -> None:
|
|
730
810
|
self._custom_setter_for_property_output_type(value)
|
|
731
811
|
|
|
@@ -777,6 +857,7 @@ class Parameter(BaseNodeElement):
|
|
|
777
857
|
def is_outgoing_type_allowed(self, target_type: str | None) -> bool:
|
|
778
858
|
return ParameterType.are_types_compatible(source_type=self.output_type, target_type=target_type)
|
|
779
859
|
|
|
860
|
+
@BaseNodeElement.emits_update_on_write
|
|
780
861
|
def set_default_value(self, value: Any) -> None:
|
|
781
862
|
self.default_value = value
|
|
782
863
|
|