griptape-nodes 0.71.0__py3-none-any.whl → 0.72.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/app/app.py +4 -0
- griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +10 -1
- griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +4 -0
- griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +4 -0
- griptape_nodes/common/node_executor.py +1 -1
- griptape_nodes/drivers/image_metadata/__init__.py +21 -0
- griptape_nodes/drivers/image_metadata/base_image_metadata_driver.py +63 -0
- griptape_nodes/drivers/image_metadata/exif_metadata_driver.py +218 -0
- griptape_nodes/drivers/image_metadata/image_metadata_driver_registry.py +55 -0
- griptape_nodes/drivers/image_metadata/png_metadata_driver.py +71 -0
- griptape_nodes/drivers/storage/base_storage_driver.py +32 -0
- griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +384 -10
- griptape_nodes/drivers/storage/local_storage_driver.py +65 -4
- griptape_nodes/drivers/thread_storage/local_thread_storage_driver.py +1 -0
- griptape_nodes/exe_types/node_groups/base_node_group.py +3 -0
- griptape_nodes/exe_types/node_types.py +13 -0
- griptape_nodes/exe_types/param_components/log_parameter.py +3 -2
- griptape_nodes/exe_types/param_types/parameter_float.py +4 -4
- griptape_nodes/exe_types/param_types/parameter_int.py +4 -4
- griptape_nodes/exe_types/param_types/parameter_number.py +34 -30
- griptape_nodes/node_library/workflow_registry.py +5 -8
- griptape_nodes/retained_mode/events/app_events.py +1 -0
- griptape_nodes/retained_mode/events/base_events.py +42 -26
- griptape_nodes/retained_mode/events/flow_events.py +67 -0
- griptape_nodes/retained_mode/events/library_events.py +1 -1
- griptape_nodes/retained_mode/events/node_events.py +1 -0
- griptape_nodes/retained_mode/events/os_events.py +22 -0
- griptape_nodes/retained_mode/events/static_file_events.py +28 -4
- griptape_nodes/retained_mode/managers/flow_manager.py +134 -0
- griptape_nodes/retained_mode/managers/image_metadata_injector.py +339 -0
- griptape_nodes/retained_mode/managers/library_manager.py +71 -41
- griptape_nodes/retained_mode/managers/model_manager.py +1 -0
- griptape_nodes/retained_mode/managers/node_manager.py +8 -5
- griptape_nodes/retained_mode/managers/os_manager.py +269 -32
- griptape_nodes/retained_mode/managers/project_manager.py +3 -7
- griptape_nodes/retained_mode/managers/session_manager.py +1 -0
- griptape_nodes/retained_mode/managers/settings.py +5 -0
- griptape_nodes/retained_mode/managers/static_files_manager.py +83 -17
- griptape_nodes/retained_mode/managers/workflow_manager.py +71 -41
- griptape_nodes/servers/static.py +31 -0
- griptape_nodes/traits/clamp.py +52 -9
- griptape_nodes/utils/__init__.py +9 -1
- griptape_nodes/utils/file_utils.py +13 -13
- griptape_nodes/utils/http_file_patch.py +613 -0
- griptape_nodes/utils/path_utils.py +58 -0
- griptape_nodes/utils/url_utils.py +106 -0
- {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/METADATA +2 -1
- {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/RECORD +50 -41
- {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/WHEEL +1 -1
- {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urljoin, urlparse
|
|
5
7
|
|
|
6
8
|
import httpx
|
|
7
9
|
|
|
8
10
|
from griptape_nodes.drivers.storage.base_storage_driver import BaseStorageDriver, CreateSignedUploadUrlResponse
|
|
9
11
|
from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy
|
|
12
|
+
from griptape_nodes.utils.path_utils import get_workspace_relative_path
|
|
10
13
|
|
|
11
14
|
logger = logging.getLogger("griptape_nodes")
|
|
12
15
|
|
|
@@ -42,22 +45,24 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
42
45
|
def create_signed_upload_url(
|
|
43
46
|
self, path: Path, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
44
47
|
) -> CreateSignedUploadUrlResponse:
|
|
48
|
+
normalized_path = get_workspace_relative_path(path, self.workspace_directory)
|
|
49
|
+
|
|
45
50
|
if existing_file_policy != ExistingFilePolicy.OVERWRITE:
|
|
46
51
|
logger.warning(
|
|
47
52
|
"Griptape Cloud storage only supports OVERWRITE policy. "
|
|
48
53
|
"Requested policy '%s' will be ignored for file: %s",
|
|
49
54
|
existing_file_policy.value,
|
|
50
|
-
|
|
55
|
+
normalized_path,
|
|
51
56
|
)
|
|
52
57
|
|
|
53
|
-
self._create_asset(
|
|
58
|
+
self._create_asset(normalized_path.as_posix())
|
|
54
59
|
|
|
55
|
-
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{
|
|
60
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{normalized_path.as_posix()}")
|
|
56
61
|
try:
|
|
57
62
|
response = httpx.post(url, json={"operation": "PUT"}, headers=self.headers)
|
|
58
63
|
response.raise_for_status()
|
|
59
64
|
except httpx.HTTPStatusError as e:
|
|
60
|
-
msg = f"Failed to create presigned upload URL for file {
|
|
65
|
+
msg = f"Failed to create presigned upload URL for file {normalized_path}: {e}"
|
|
61
66
|
logger.error(msg)
|
|
62
67
|
raise RuntimeError(msg) from e
|
|
63
68
|
|
|
@@ -67,16 +72,77 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
67
72
|
"url": response_data["url"],
|
|
68
73
|
"headers": response_data.get("headers", {}),
|
|
69
74
|
"method": "PUT",
|
|
70
|
-
"file_path": str(
|
|
75
|
+
"file_path": str(normalized_path),
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
def _parse_cloud_asset_path(self, path: str | Path) -> Path:
|
|
79
|
+
"""Parse cloud asset URL path to extract workspace-relative portion.
|
|
80
|
+
|
|
81
|
+
Handles multiple input formats:
|
|
82
|
+
- Full URLs: https://cloud.griptape.ai/buckets/{id}/assets/{path}
|
|
83
|
+
- Path-only: /buckets/{id}/assets/{path}
|
|
84
|
+
- Workspace-relative: {path}
|
|
85
|
+
|
|
86
|
+
When a full URL is provided, validates that the domain matches
|
|
87
|
+
self.base_url to prevent cross-environment URL mixing.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
path: String or Path object that may contain a cloud asset URL pattern
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Workspace-relative path
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: When full URL domain doesn't match configured base_url
|
|
97
|
+
"""
|
|
98
|
+
# Convert to string for processing
|
|
99
|
+
path_str = str(path) if isinstance(path, Path) else path
|
|
100
|
+
|
|
101
|
+
# Validate domain for full URLs (only if it contains :// scheme separator)
|
|
102
|
+
if "://" in path_str and path_str.startswith(("http://", "https://")):
|
|
103
|
+
input_parsed = urlparse(path_str)
|
|
104
|
+
input_domain = input_parsed.netloc.lower()
|
|
105
|
+
|
|
106
|
+
base_parsed = urlparse(self.base_url)
|
|
107
|
+
expected_domain = base_parsed.netloc.lower()
|
|
108
|
+
|
|
109
|
+
if input_domain != expected_domain:
|
|
110
|
+
msg = (
|
|
111
|
+
f"Invalid cloud asset URL: domain '{input_domain}' does not match "
|
|
112
|
+
f"configured base URL '{self.base_url}'. "
|
|
113
|
+
f"Expected domain: '{expected_domain}'"
|
|
114
|
+
)
|
|
115
|
+
logger.error(msg)
|
|
116
|
+
raise ValueError(msg)
|
|
117
|
+
|
|
118
|
+
# Extract path component for further processing
|
|
119
|
+
path_str = input_parsed.path.lstrip("/")
|
|
120
|
+
|
|
121
|
+
# Check if it's a cloud asset URL pattern
|
|
122
|
+
# Handle both /buckets/ and buckets/ (after leading slash removal)
|
|
123
|
+
has_buckets = "/buckets/" in path_str or path_str.startswith("buckets/")
|
|
124
|
+
has_assets = "/assets/" in path_str
|
|
125
|
+
if has_buckets and has_assets:
|
|
126
|
+
# Extract workspace-relative path from cloud URL
|
|
127
|
+
# Format: /buckets/{bucket_id}/assets/{workspace_relative_path} or buckets/{bucket_id}/assets/{workspace_relative_path}
|
|
128
|
+
parts = path_str.split("/assets/", 1)
|
|
129
|
+
expected_parts_count = 2
|
|
130
|
+
if len(parts) == expected_parts_count:
|
|
131
|
+
return Path(parts[1]) # Return the workspace-relative path after /assets/
|
|
132
|
+
|
|
133
|
+
# For non-cloud paths, return as-is
|
|
134
|
+
return Path(path_str)
|
|
135
|
+
|
|
73
136
|
def create_signed_download_url(self, path: Path) -> str:
|
|
74
|
-
|
|
137
|
+
# Parse cloud asset URLs before normalizing
|
|
138
|
+
parsed_path = self._parse_cloud_asset_path(path)
|
|
139
|
+
normalized_path = get_workspace_relative_path(parsed_path, self.workspace_directory)
|
|
140
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{normalized_path.as_posix()}")
|
|
75
141
|
try:
|
|
76
142
|
response = httpx.post(url, json={"method": "GET"}, headers=self.headers)
|
|
77
143
|
response.raise_for_status()
|
|
78
144
|
except httpx.HTTPStatusError as e:
|
|
79
|
-
msg = f"Failed to create presigned download URL for file {
|
|
145
|
+
msg = f"Failed to create presigned download URL for file {normalized_path}: {e}"
|
|
80
146
|
logger.error(msg)
|
|
81
147
|
raise RuntimeError(msg) from e
|
|
82
148
|
|
|
@@ -84,6 +150,51 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
84
150
|
|
|
85
151
|
return response_data["url"]
|
|
86
152
|
|
|
153
|
+
def save_file(
|
|
154
|
+
self, path: Path, file_content: bytes, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Save a file to cloud storage via HTTP upload.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: The path of the file to save.
|
|
160
|
+
file_content: The file content as bytes.
|
|
161
|
+
existing_file_policy: How to handle existing files. Defaults to OVERWRITE.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The full asset URL for the saved file.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
RuntimeError: If file upload fails.
|
|
168
|
+
"""
|
|
169
|
+
normalized_path = get_workspace_relative_path(path, self.workspace_directory)
|
|
170
|
+
|
|
171
|
+
if existing_file_policy != ExistingFilePolicy.OVERWRITE:
|
|
172
|
+
logger.warning(
|
|
173
|
+
"GriptapeCloudStorageDriver only supports OVERWRITE policy, got %s. "
|
|
174
|
+
"The file will be overwritten if it exists.",
|
|
175
|
+
existing_file_policy,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Get signed upload URL
|
|
179
|
+
upload_response = self.create_signed_upload_url(path, existing_file_policy)
|
|
180
|
+
|
|
181
|
+
# Upload the file using the signed URL
|
|
182
|
+
try:
|
|
183
|
+
response = httpx.request(
|
|
184
|
+
upload_response["method"],
|
|
185
|
+
upload_response["url"],
|
|
186
|
+
content=file_content,
|
|
187
|
+
headers=upload_response["headers"],
|
|
188
|
+
)
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
except httpx.HTTPStatusError as e:
|
|
191
|
+
msg = f"Failed to upload file {normalized_path}: {e}"
|
|
192
|
+
logger.error(msg)
|
|
193
|
+
raise RuntimeError(msg) from e
|
|
194
|
+
|
|
195
|
+
# Return the full asset URL
|
|
196
|
+
return urljoin(self.base_url, f"/buckets/{self.bucket_id}/assets/{normalized_path.as_posix()}")
|
|
197
|
+
|
|
87
198
|
def _create_asset(self, asset_name: str) -> str:
|
|
88
199
|
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/assets")
|
|
89
200
|
try:
|
|
@@ -160,6 +271,22 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
160
271
|
|
|
161
272
|
return file_names
|
|
162
273
|
|
|
274
|
+
def get_asset_url(self, path: Path) -> str:
|
|
275
|
+
"""Get the permanent unsigned URL for a cloud asset.
|
|
276
|
+
|
|
277
|
+
Returns the permanent public URL for the asset (not the presigned URL).
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
path: The path of the file
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Permanent cloud asset URL
|
|
284
|
+
"""
|
|
285
|
+
# Parse cloud asset URLs before normalizing
|
|
286
|
+
parsed_path = self._parse_cloud_asset_path(path)
|
|
287
|
+
normalized_path = get_workspace_relative_path(parsed_path, self.workspace_directory)
|
|
288
|
+
return urljoin(self.base_url, f"/buckets/{self.bucket_id}/assets/{normalized_path.as_posix()}")
|
|
289
|
+
|
|
163
290
|
@staticmethod
|
|
164
291
|
def list_buckets(*, base_url: str, api_key: str) -> list[dict]:
|
|
165
292
|
"""List all buckets in Griptape Cloud.
|
|
@@ -190,12 +317,259 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
190
317
|
Args:
|
|
191
318
|
path: The path of the file to delete.
|
|
192
319
|
"""
|
|
193
|
-
|
|
320
|
+
normalized_path = get_workspace_relative_path(path, self.workspace_directory)
|
|
321
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/assets/{normalized_path.as_posix()}")
|
|
194
322
|
|
|
195
323
|
try:
|
|
196
324
|
response = httpx.delete(url, headers=self.headers)
|
|
197
325
|
response.raise_for_status()
|
|
198
326
|
except httpx.HTTPStatusError as e:
|
|
199
|
-
msg = f"Failed to delete file {
|
|
327
|
+
msg = f"Failed to delete file {normalized_path}: {e}"
|
|
200
328
|
logger.error(msg)
|
|
201
329
|
raise RuntimeError(msg) from e
|
|
330
|
+
|
|
331
|
+
def _is_cloud_asset_url(self, url_str: str) -> bool:
|
|
332
|
+
"""Check if URL is a Griptape Cloud asset URL with domain validation.
|
|
333
|
+
|
|
334
|
+
Detects URLs matching pattern: https://cloud.griptape.ai/buckets/{id}/assets/{path}
|
|
335
|
+
Validates domain matches expected cloud domain.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
url_str: String to check
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
True if url_str is a valid cloud asset URL
|
|
342
|
+
"""
|
|
343
|
+
# Fast negative checks first
|
|
344
|
+
if not url_str:
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
# Must be a full URL with scheme
|
|
348
|
+
if not url_str.startswith(("http://", "https://")):
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
# Parse URL to check domain
|
|
352
|
+
parsed = urlparse(url_str)
|
|
353
|
+
domain = parsed.netloc.lower()
|
|
354
|
+
|
|
355
|
+
# Get expected cloud domain from instance
|
|
356
|
+
expected_parsed = urlparse(self.base_url)
|
|
357
|
+
expected_domain = expected_parsed.netloc.lower()
|
|
358
|
+
|
|
359
|
+
# Domain must match
|
|
360
|
+
if domain != expected_domain:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
# Must contain both /buckets/ and /assets/ patterns
|
|
364
|
+
path = parsed.path
|
|
365
|
+
has_buckets = "/buckets/" in path
|
|
366
|
+
has_assets = "/assets/" in path
|
|
367
|
+
|
|
368
|
+
# Success path - valid cloud asset URL
|
|
369
|
+
return has_buckets and has_assets
|
|
370
|
+
|
|
371
|
+
def _extract_workspace_path_from_cloud_url(self, url_str: str) -> str | None:
|
|
372
|
+
"""Extract workspace-relative path from cloud asset URL.
|
|
373
|
+
|
|
374
|
+
Parses URLs like: /buckets/{bucket_id}/assets/{workspace_path}
|
|
375
|
+
Returns just the {workspace_path} portion.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
url_str: Cloud asset URL
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Workspace-relative path, or None if parsing fails
|
|
382
|
+
"""
|
|
383
|
+
parsed = urlparse(url_str)
|
|
384
|
+
path = parsed.path
|
|
385
|
+
|
|
386
|
+
# Extract workspace-relative path from: /buckets/{bucket_id}/assets/{workspace_path}
|
|
387
|
+
expected_parts = 2
|
|
388
|
+
try:
|
|
389
|
+
parts = path.split("/assets/", 1)
|
|
390
|
+
if len(parts) != expected_parts:
|
|
391
|
+
return None
|
|
392
|
+
return parts[1]
|
|
393
|
+
except Exception:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
def _create_signed_download_url_from_asset_url(self, asset_url: str) -> str | None:
|
|
397
|
+
"""Create a signed download URL for a cloud asset.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
asset_url: Cloud asset URL to convert
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Signed download URL if successful, None if fails
|
|
404
|
+
"""
|
|
405
|
+
# Extract workspace-relative path
|
|
406
|
+
workspace_path = self._extract_workspace_path_from_cloud_url(asset_url)
|
|
407
|
+
if not workspace_path:
|
|
408
|
+
logger.debug("Could not extract workspace path from cloud URL: %s", asset_url)
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
# Build API URL for signed download URL
|
|
412
|
+
api_url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{workspace_path}")
|
|
413
|
+
|
|
414
|
+
# Make API request to get signed URL
|
|
415
|
+
try:
|
|
416
|
+
response = httpx.post(api_url, json={"method": "GET"}, headers=self.headers)
|
|
417
|
+
response.raise_for_status()
|
|
418
|
+
|
|
419
|
+
response_data = response.json()
|
|
420
|
+
signed_url = response_data["url"]
|
|
421
|
+
|
|
422
|
+
logger.info("Converted cloud asset URL to signed URL: %s", asset_url)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
425
|
+
logger.warning(
|
|
426
|
+
"Failed to create signed download URL for %s: HTTP %s", asset_url, e.response.status_code
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
logger.warning("Failed to create signed download URL for %s: %s", asset_url, e)
|
|
430
|
+
return None
|
|
431
|
+
else:
|
|
432
|
+
return signed_url
|
|
433
|
+
|
|
434
|
+
@staticmethod
|
|
435
|
+
def is_cloud_asset_url(url_str: str, base_url: str | None = None) -> bool:
|
|
436
|
+
"""Check if URL is a Griptape Cloud asset URL with domain validation.
|
|
437
|
+
|
|
438
|
+
Static version for use without driver instance (e.g., httpx patching layer).
|
|
439
|
+
Detects URLs matching pattern: https://cloud.griptape.ai/buckets/{id}/assets/{path}
|
|
440
|
+
Validates domain matches expected cloud domain.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
url_str: String to check
|
|
444
|
+
base_url: Expected cloud domain URL. If None, reads from GT_CLOUD_BASE_URL env var.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
True if url_str is a valid cloud asset URL
|
|
448
|
+
"""
|
|
449
|
+
# Fast negative checks first
|
|
450
|
+
if not url_str:
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
# Must be a full URL with scheme
|
|
454
|
+
if not url_str.startswith(("http://", "https://")):
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
# Parse URL to check domain
|
|
458
|
+
parsed = urlparse(url_str)
|
|
459
|
+
domain = parsed.netloc.lower()
|
|
460
|
+
|
|
461
|
+
# Get expected cloud domain from parameter or environment
|
|
462
|
+
if base_url is None:
|
|
463
|
+
base_url = os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
|
|
464
|
+
|
|
465
|
+
expected_parsed = urlparse(base_url)
|
|
466
|
+
expected_domain = expected_parsed.netloc.lower()
|
|
467
|
+
|
|
468
|
+
# Domain must match
|
|
469
|
+
if domain != expected_domain:
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
# Must contain both /buckets/ and /assets/ patterns
|
|
473
|
+
path = parsed.path
|
|
474
|
+
has_buckets = "/buckets/" in path
|
|
475
|
+
has_assets = "/assets/" in path
|
|
476
|
+
|
|
477
|
+
# Success path - valid cloud asset URL
|
|
478
|
+
return has_buckets and has_assets
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def extract_workspace_path_from_cloud_url(url_str: str) -> str | None:
|
|
482
|
+
"""Extract workspace-relative path from cloud asset URL.
|
|
483
|
+
|
|
484
|
+
Static version for use without driver instance.
|
|
485
|
+
Parses URLs like: /buckets/{bucket_id}/assets/{workspace_path}
|
|
486
|
+
Returns just the {workspace_path} portion.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
url_str: Cloud asset URL
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Workspace-relative path, or None if parsing fails
|
|
493
|
+
"""
|
|
494
|
+
parsed = urlparse(url_str)
|
|
495
|
+
path = parsed.path
|
|
496
|
+
|
|
497
|
+
# Extract workspace-relative path from: /buckets/{bucket_id}/assets/{workspace_path}
|
|
498
|
+
expected_parts = 2
|
|
499
|
+
try:
|
|
500
|
+
parts = path.split("/assets/", 1)
|
|
501
|
+
if len(parts) != expected_parts:
|
|
502
|
+
return None
|
|
503
|
+
return parts[1]
|
|
504
|
+
except Exception:
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def create_signed_download_url_from_asset_url(
|
|
509
|
+
asset_url: str,
|
|
510
|
+
bucket_id: str | None = None,
|
|
511
|
+
api_key: str | None = None,
|
|
512
|
+
base_url: str | None = None,
|
|
513
|
+
*,
|
|
514
|
+
httpx_request_func: Callable[..., Any],
|
|
515
|
+
) -> str | None:
|
|
516
|
+
"""Create a signed download URL for a cloud asset.
|
|
517
|
+
|
|
518
|
+
Static version for use without driver instance (e.g., httpx patching layer).
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
asset_url: Cloud asset URL to convert
|
|
522
|
+
bucket_id: Bucket ID. If None, reads from GT_CLOUD_BUCKET_ID env var.
|
|
523
|
+
api_key: API key. If None, reads from GT_CLOUD_API_KEY env var.
|
|
524
|
+
base_url: Cloud base URL. If None, reads from GT_CLOUD_BASE_URL env var.
|
|
525
|
+
httpx_request_func: The httpx request function to use (original, not patched)
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Signed download URL if successful, None if fails
|
|
529
|
+
"""
|
|
530
|
+
# Get credentials from parameters or environment
|
|
531
|
+
if bucket_id is None:
|
|
532
|
+
bucket_id = os.environ.get("GT_CLOUD_BUCKET_ID")
|
|
533
|
+
if api_key is None:
|
|
534
|
+
api_key = os.environ.get("GT_CLOUD_API_KEY")
|
|
535
|
+
if base_url is None:
|
|
536
|
+
base_url = os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
|
|
537
|
+
|
|
538
|
+
# Guard: Check for required credentials
|
|
539
|
+
if not bucket_id:
|
|
540
|
+
logger.debug("GT_CLOUD_BUCKET_ID not set, skipping cloud URL conversion: %s", asset_url)
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
if not api_key:
|
|
544
|
+
logger.debug("GT_CLOUD_API_KEY not set, skipping cloud URL conversion: %s", asset_url)
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
# Extract workspace-relative path
|
|
548
|
+
workspace_path = GriptapeCloudStorageDriver.extract_workspace_path_from_cloud_url(asset_url)
|
|
549
|
+
if not workspace_path:
|
|
550
|
+
logger.debug("Could not extract workspace path from cloud URL: %s", asset_url)
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
# Build API URL for signed download URL
|
|
554
|
+
api_url = urljoin(base_url, f"/api/buckets/{bucket_id}/asset-urls/{workspace_path}")
|
|
555
|
+
|
|
556
|
+
# Make API request to get signed URL
|
|
557
|
+
try:
|
|
558
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
559
|
+
response = httpx_request_func("POST", api_url, json={"method": "GET"}, headers=headers)
|
|
560
|
+
response.raise_for_status()
|
|
561
|
+
|
|
562
|
+
response_data = response.json()
|
|
563
|
+
signed_url = response_data["url"]
|
|
564
|
+
|
|
565
|
+
logger.info("Converted cloud asset URL to signed URL: %s", asset_url)
|
|
566
|
+
except Exception as e:
|
|
567
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
568
|
+
logger.warning(
|
|
569
|
+
"Failed to create signed download URL for %s: HTTP %s", asset_url, e.response.status_code
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
logger.warning("Failed to create signed download URL for %s: %s", asset_url, e)
|
|
573
|
+
return None
|
|
574
|
+
else:
|
|
575
|
+
return signed_url
|
|
@@ -6,8 +6,9 @@ from urllib.parse import urljoin
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
8
|
from griptape_nodes.drivers.storage.base_storage_driver import BaseStorageDriver, CreateSignedUploadUrlResponse
|
|
9
|
-
from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy, WriteFileRequest
|
|
9
|
+
from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy, WriteFileRequest, WriteFileResultSuccess
|
|
10
10
|
from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
|
|
11
|
+
from griptape_nodes.utils import resolve_workspace_path
|
|
11
12
|
|
|
12
13
|
logger = logging.getLogger("griptape_nodes")
|
|
13
14
|
|
|
@@ -44,7 +45,7 @@ class LocalStorageDriver(BaseStorageDriver):
|
|
|
44
45
|
self, path: Path, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
45
46
|
) -> CreateSignedUploadUrlResponse:
|
|
46
47
|
# on_write_file_request seems to work most reliably with an absolute path.
|
|
47
|
-
absolute_path = path
|
|
48
|
+
absolute_path = resolve_workspace_path(path, self.workspace_directory)
|
|
48
49
|
|
|
49
50
|
# Always delegate to OSManager for file path resolution and policy handling.
|
|
50
51
|
# Creating an empty file before the upload url gives us a chance to claim ownership
|
|
@@ -94,9 +95,55 @@ class LocalStorageDriver(BaseStorageDriver):
|
|
|
94
95
|
"file_path": str(resolved_path),
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
def save_file(
|
|
99
|
+
self, path: Path, file_content: bytes, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Save a file to local storage by writing directly to disk.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: The path of the file to save.
|
|
105
|
+
file_content: The file content as bytes.
|
|
106
|
+
existing_file_policy: How to handle existing files. Defaults to OVERWRITE.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The absolute file path where the file was saved.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
FileExistsError: When existing_file_policy is FAIL and file already exists.
|
|
113
|
+
RuntimeError: If file write fails.
|
|
114
|
+
"""
|
|
115
|
+
absolute_path = resolve_workspace_path(path, self.workspace_directory)
|
|
116
|
+
|
|
117
|
+
result = GriptapeNodes.OSManager().on_write_file_request(
|
|
118
|
+
WriteFileRequest(
|
|
119
|
+
file_path=str(absolute_path),
|
|
120
|
+
content=file_content,
|
|
121
|
+
existing_file_policy=existing_file_policy,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not isinstance(result, WriteFileResultSuccess):
|
|
126
|
+
msg = f"Failed to write file {path}: {result.result_details}"
|
|
127
|
+
raise ValueError(msg) # noqa: TRY004
|
|
128
|
+
|
|
129
|
+
return result.final_file_path
|
|
130
|
+
|
|
97
131
|
def create_signed_download_url(self, path: Path) -> str:
|
|
98
|
-
#
|
|
99
|
-
|
|
132
|
+
# Resolve path, treating relative paths as workspace-relative
|
|
133
|
+
absolute_path = resolve_workspace_path(path, self.workspace_directory)
|
|
134
|
+
|
|
135
|
+
# Automatically determine if the file is external to the workspace
|
|
136
|
+
try:
|
|
137
|
+
workspace_relative_path = absolute_path.relative_to(self.workspace_directory.resolve())
|
|
138
|
+
# Internal files: use workspace-relative path
|
|
139
|
+
url = f"{self.base_url}/{workspace_relative_path.as_posix()}"
|
|
140
|
+
except ValueError:
|
|
141
|
+
# For external files, use /external path and strip leading slash from absolute path
|
|
142
|
+
path_str = str(absolute_path).removeprefix("/")
|
|
143
|
+
# Build URL with /external prefix, replacing the /workspace part of base_url
|
|
144
|
+
base_without_workspace = self.base_url.rsplit("/workspace", 1)[0]
|
|
145
|
+
url = f"{base_without_workspace}/external/{path_str}"
|
|
146
|
+
|
|
100
147
|
# Add a cache-busting query parameter to the URL so that the browser always reloads the file
|
|
101
148
|
cache_busted_url = f"{url}?t={int(time.time())}"
|
|
102
149
|
return cache_busted_url
|
|
@@ -137,3 +184,17 @@ class LocalStorageDriver(BaseStorageDriver):
|
|
|
137
184
|
|
|
138
185
|
response_data = response.json()
|
|
139
186
|
return response_data.get("files", [])
|
|
187
|
+
|
|
188
|
+
def get_asset_url(self, path: Path) -> str:
|
|
189
|
+
"""Get the permanent URL for a local asset.
|
|
190
|
+
|
|
191
|
+
Returns the absolute file path.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
path: The path of the file
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Absolute file path as a string
|
|
198
|
+
"""
|
|
199
|
+
absolute_path = resolve_workspace_path(path, self.workspace_directory)
|
|
200
|
+
return str(absolute_path)
|
|
@@ -117,6 +117,7 @@ class LocalThreadStorageDriver(BaseThreadStorageDriver):
|
|
|
117
117
|
msg = f"Cannot delete thread {thread_id}. Archive it first."
|
|
118
118
|
raise ValueError(msg)
|
|
119
119
|
|
|
120
|
+
# TODO: Replace with DeleteFileRequest https://github.com/griptape-ai/griptape-nodes/issues/3765
|
|
120
121
|
thread_file.unlink()
|
|
121
122
|
|
|
122
123
|
def thread_exists(self, thread_id: str) -> bool:
|
|
@@ -80,6 +80,9 @@ class BaseNodeGroup(BaseNode):
|
|
|
80
80
|
if node.name in self.nodes:
|
|
81
81
|
del self.nodes[node.name]
|
|
82
82
|
|
|
83
|
+
node_names_in_group = set(self.nodes.keys())
|
|
84
|
+
self.metadata["node_names_in_group"] = list(node_names_in_group)
|
|
85
|
+
|
|
83
86
|
def _add_nodes_to_group_dict(self, nodes: list[BaseNode]) -> None:
|
|
84
87
|
"""Add nodes to the group's node dictionary."""
|
|
85
88
|
for node in nodes:
|
|
@@ -48,6 +48,7 @@ logger = logging.getLogger("griptape_nodes")
|
|
|
48
48
|
T = TypeVar("T")
|
|
49
49
|
|
|
50
50
|
NODE_GROUP_FLOW = "NodeGroupFlow"
|
|
51
|
+
NODE_DEFAULT_SIZE = {"width": 400, "height": 320}
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
class TransformedParameterValue(NamedTuple):
|
|
@@ -830,6 +831,18 @@ class BaseNode(ABC):
|
|
|
830
831
|
emit_change=False,
|
|
831
832
|
)
|
|
832
833
|
|
|
834
|
+
def set_initial_node_size(
|
|
835
|
+
self, width: int = NODE_DEFAULT_SIZE["width"], height: int = NODE_DEFAULT_SIZE["height"]
|
|
836
|
+
) -> None:
|
|
837
|
+
"""Set the node's UI size. Node authors can call this to give the node a default or custom size.
|
|
838
|
+
|
|
839
|
+
Args:
|
|
840
|
+
width: Width in pixels.
|
|
841
|
+
height: Height in pixels.
|
|
842
|
+
"""
|
|
843
|
+
if "size" not in self.metadata:
|
|
844
|
+
self.metadata["size"] = {"width": width, "height": height}
|
|
845
|
+
|
|
833
846
|
def kill_parameter_children(self, parameter: Parameter) -> None:
|
|
834
847
|
from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
|
|
835
848
|
|
|
@@ -4,6 +4,7 @@ import sys
|
|
|
4
4
|
import time
|
|
5
5
|
from collections.abc import Callable, Iterator
|
|
6
6
|
from types import TracebackType
|
|
7
|
+
from typing import Self
|
|
7
8
|
|
|
8
9
|
from griptape_nodes.exe_types.core_types import Parameter, ParameterMode
|
|
9
10
|
from griptape_nodes.exe_types.node_types import BaseNode
|
|
@@ -71,7 +72,7 @@ class StdoutCapture:
|
|
|
71
72
|
def isatty(self) -> bool:
|
|
72
73
|
return self._original_stdout.isatty()
|
|
73
74
|
|
|
74
|
-
def __enter__(self) ->
|
|
75
|
+
def __enter__(self) -> Self:
|
|
75
76
|
sys.stdout = self
|
|
76
77
|
return self
|
|
77
78
|
|
|
@@ -100,7 +101,7 @@ class LoggerCapture:
|
|
|
100
101
|
self.target_level = level
|
|
101
102
|
self._handler = CallbackHandler(callback)
|
|
102
103
|
|
|
103
|
-
def __enter__(self) ->
|
|
104
|
+
def __enter__(self) -> Self:
|
|
104
105
|
self.original_level = self.logger.level
|
|
105
106
|
self.logger.setLevel(self.target_level)
|
|
106
107
|
self.logger.addHandler(self._handler)
|
|
@@ -43,8 +43,8 @@ class ParameterFloat(ParameterNumber):
|
|
|
43
43
|
ui_options: dict | None = None,
|
|
44
44
|
step: float | None = None,
|
|
45
45
|
slider: bool = False,
|
|
46
|
-
min_val: float =
|
|
47
|
-
max_val: float =
|
|
46
|
+
min_val: float | None = None,
|
|
47
|
+
max_val: float | None = None,
|
|
48
48
|
validate_min_max: bool = False,
|
|
49
49
|
accept_any: bool = True,
|
|
50
50
|
hide: bool | None = None,
|
|
@@ -77,8 +77,8 @@ class ParameterFloat(ParameterNumber):
|
|
|
77
77
|
ui_options: Dictionary of UI options
|
|
78
78
|
step: Step size for numeric input controls
|
|
79
79
|
slider: Whether to use slider trait
|
|
80
|
-
min_val: Minimum value for constraints
|
|
81
|
-
max_val: Maximum value for constraints
|
|
80
|
+
min_val: Minimum value for constraints (None to disable constraints)
|
|
81
|
+
max_val: Maximum value for constraints (None to disable constraints)
|
|
82
82
|
validate_min_max: Whether to validate min/max with error
|
|
83
83
|
accept_any: Whether to accept any input type and convert to float (default: True)
|
|
84
84
|
hide: Whether to hide the entire parameter
|
|
@@ -43,8 +43,8 @@ class ParameterInt(ParameterNumber):
|
|
|
43
43
|
ui_options: dict | None = None,
|
|
44
44
|
step: int | None = None,
|
|
45
45
|
slider: bool = False,
|
|
46
|
-
min_val: float =
|
|
47
|
-
max_val: float =
|
|
46
|
+
min_val: float | None = None,
|
|
47
|
+
max_val: float | None = None,
|
|
48
48
|
validate_min_max: bool = False,
|
|
49
49
|
accept_any: bool = True,
|
|
50
50
|
hide: bool | None = None,
|
|
@@ -77,8 +77,8 @@ class ParameterInt(ParameterNumber):
|
|
|
77
77
|
ui_options: Dictionary of UI options
|
|
78
78
|
step: Step size for numeric input controls
|
|
79
79
|
slider: Whether to use slider trait
|
|
80
|
-
min_val: Minimum value for constraints
|
|
81
|
-
max_val: Maximum value for constraints
|
|
80
|
+
min_val: Minimum value for constraints (None to disable constraints)
|
|
81
|
+
max_val: Maximum value for constraints (None to disable constraints)
|
|
82
82
|
validate_min_max: Whether to validate min/max with error
|
|
83
83
|
accept_any: Whether to accept any input type and convert to integer (default: True)
|
|
84
84
|
hide: Whether to hide the entire parameter
|