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.
Files changed (50) hide show
  1. griptape_nodes/app/app.py +4 -0
  2. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +10 -1
  3. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +4 -0
  4. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +4 -0
  5. griptape_nodes/common/node_executor.py +1 -1
  6. griptape_nodes/drivers/image_metadata/__init__.py +21 -0
  7. griptape_nodes/drivers/image_metadata/base_image_metadata_driver.py +63 -0
  8. griptape_nodes/drivers/image_metadata/exif_metadata_driver.py +218 -0
  9. griptape_nodes/drivers/image_metadata/image_metadata_driver_registry.py +55 -0
  10. griptape_nodes/drivers/image_metadata/png_metadata_driver.py +71 -0
  11. griptape_nodes/drivers/storage/base_storage_driver.py +32 -0
  12. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +384 -10
  13. griptape_nodes/drivers/storage/local_storage_driver.py +65 -4
  14. griptape_nodes/drivers/thread_storage/local_thread_storage_driver.py +1 -0
  15. griptape_nodes/exe_types/node_groups/base_node_group.py +3 -0
  16. griptape_nodes/exe_types/node_types.py +13 -0
  17. griptape_nodes/exe_types/param_components/log_parameter.py +3 -2
  18. griptape_nodes/exe_types/param_types/parameter_float.py +4 -4
  19. griptape_nodes/exe_types/param_types/parameter_int.py +4 -4
  20. griptape_nodes/exe_types/param_types/parameter_number.py +34 -30
  21. griptape_nodes/node_library/workflow_registry.py +5 -8
  22. griptape_nodes/retained_mode/events/app_events.py +1 -0
  23. griptape_nodes/retained_mode/events/base_events.py +42 -26
  24. griptape_nodes/retained_mode/events/flow_events.py +67 -0
  25. griptape_nodes/retained_mode/events/library_events.py +1 -1
  26. griptape_nodes/retained_mode/events/node_events.py +1 -0
  27. griptape_nodes/retained_mode/events/os_events.py +22 -0
  28. griptape_nodes/retained_mode/events/static_file_events.py +28 -4
  29. griptape_nodes/retained_mode/managers/flow_manager.py +134 -0
  30. griptape_nodes/retained_mode/managers/image_metadata_injector.py +339 -0
  31. griptape_nodes/retained_mode/managers/library_manager.py +71 -41
  32. griptape_nodes/retained_mode/managers/model_manager.py +1 -0
  33. griptape_nodes/retained_mode/managers/node_manager.py +8 -5
  34. griptape_nodes/retained_mode/managers/os_manager.py +269 -32
  35. griptape_nodes/retained_mode/managers/project_manager.py +3 -7
  36. griptape_nodes/retained_mode/managers/session_manager.py +1 -0
  37. griptape_nodes/retained_mode/managers/settings.py +5 -0
  38. griptape_nodes/retained_mode/managers/static_files_manager.py +83 -17
  39. griptape_nodes/retained_mode/managers/workflow_manager.py +71 -41
  40. griptape_nodes/servers/static.py +31 -0
  41. griptape_nodes/traits/clamp.py +52 -9
  42. griptape_nodes/utils/__init__.py +9 -1
  43. griptape_nodes/utils/file_utils.py +13 -13
  44. griptape_nodes/utils/http_file_patch.py +613 -0
  45. griptape_nodes/utils/path_utils.py +58 -0
  46. griptape_nodes/utils/url_utils.py +106 -0
  47. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/METADATA +2 -1
  48. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/RECORD +50 -41
  49. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.0.dist-info}/WHEEL +1 -1
  50. {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 urllib.parse import urljoin
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
- path,
55
+ normalized_path,
51
56
  )
52
57
 
53
- self._create_asset(path.as_posix())
58
+ self._create_asset(normalized_path.as_posix())
54
59
 
55
- url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{path.as_posix()}")
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 {path}: {e}"
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(path),
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
- url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{path.as_posix()}")
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 {path}: {e}"
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
- url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/assets/{path.as_posix()}")
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 {path}: {e}"
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 if path.is_absolute() else self.workspace_directory / 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
- # The base_url already includes the /static path, so just append the path
99
- url = f"{self.base_url}/{path.as_posix()}"
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) -> "StdoutCapture":
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) -> "LoggerCapture":
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 = 0,
47
- max_val: float = 100,
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 = 0,
47
- max_val: float = 100,
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