griptape-nodes 0.70.1__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 (67) hide show
  1. griptape_nodes/api_client/client.py +8 -5
  2. griptape_nodes/app/app.py +4 -0
  3. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +48 -9
  4. griptape_nodes/bootstrap/utils/subprocess_websocket_base.py +88 -0
  5. griptape_nodes/bootstrap/utils/subprocess_websocket_listener.py +126 -0
  6. griptape_nodes/bootstrap/utils/subprocess_websocket_sender.py +121 -0
  7. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +17 -170
  8. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +10 -1
  9. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +13 -117
  10. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +4 -0
  11. griptape_nodes/bootstrap/workflow_publishers/local_session_workflow_publisher.py +206 -0
  12. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +22 -3
  13. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +49 -25
  14. griptape_nodes/common/node_executor.py +61 -14
  15. griptape_nodes/drivers/image_metadata/__init__.py +21 -0
  16. griptape_nodes/drivers/image_metadata/base_image_metadata_driver.py +63 -0
  17. griptape_nodes/drivers/image_metadata/exif_metadata_driver.py +218 -0
  18. griptape_nodes/drivers/image_metadata/image_metadata_driver_registry.py +55 -0
  19. griptape_nodes/drivers/image_metadata/png_metadata_driver.py +71 -0
  20. griptape_nodes/drivers/storage/base_storage_driver.py +32 -0
  21. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +384 -10
  22. griptape_nodes/drivers/storage/local_storage_driver.py +65 -4
  23. griptape_nodes/drivers/thread_storage/local_thread_storage_driver.py +1 -0
  24. griptape_nodes/exe_types/base_iterative_nodes.py +1 -1
  25. griptape_nodes/exe_types/node_groups/base_node_group.py +3 -0
  26. griptape_nodes/exe_types/node_groups/subflow_node_group.py +18 -0
  27. griptape_nodes/exe_types/node_types.py +13 -0
  28. griptape_nodes/exe_types/param_components/log_parameter.py +4 -4
  29. griptape_nodes/exe_types/param_components/subflow_execution_component.py +329 -0
  30. griptape_nodes/exe_types/param_types/parameter_audio.py +17 -2
  31. griptape_nodes/exe_types/param_types/parameter_float.py +4 -4
  32. griptape_nodes/exe_types/param_types/parameter_image.py +14 -1
  33. griptape_nodes/exe_types/param_types/parameter_int.py +4 -4
  34. griptape_nodes/exe_types/param_types/parameter_number.py +12 -14
  35. griptape_nodes/exe_types/param_types/parameter_three_d.py +14 -1
  36. griptape_nodes/exe_types/param_types/parameter_video.py +17 -2
  37. griptape_nodes/node_library/workflow_registry.py +5 -8
  38. griptape_nodes/retained_mode/events/app_events.py +1 -0
  39. griptape_nodes/retained_mode/events/base_events.py +42 -26
  40. griptape_nodes/retained_mode/events/flow_events.py +67 -0
  41. griptape_nodes/retained_mode/events/library_events.py +1 -1
  42. griptape_nodes/retained_mode/events/node_events.py +1 -0
  43. griptape_nodes/retained_mode/events/os_events.py +22 -0
  44. griptape_nodes/retained_mode/events/static_file_events.py +28 -4
  45. griptape_nodes/retained_mode/managers/flow_manager.py +134 -0
  46. griptape_nodes/retained_mode/managers/image_metadata_injector.py +339 -0
  47. griptape_nodes/retained_mode/managers/library_manager.py +71 -41
  48. griptape_nodes/retained_mode/managers/model_manager.py +1 -0
  49. griptape_nodes/retained_mode/managers/node_manager.py +8 -5
  50. griptape_nodes/retained_mode/managers/os_manager.py +270 -33
  51. griptape_nodes/retained_mode/managers/project_manager.py +3 -7
  52. griptape_nodes/retained_mode/managers/session_manager.py +1 -0
  53. griptape_nodes/retained_mode/managers/settings.py +5 -0
  54. griptape_nodes/retained_mode/managers/static_files_manager.py +83 -17
  55. griptape_nodes/retained_mode/managers/workflow_manager.py +71 -41
  56. griptape_nodes/servers/static.py +31 -0
  57. griptape_nodes/utils/__init__.py +9 -1
  58. griptape_nodes/utils/artifact_normalization.py +245 -0
  59. griptape_nodes/utils/file_utils.py +13 -13
  60. griptape_nodes/utils/http_file_patch.py +613 -0
  61. griptape_nodes/utils/image_preview.py +27 -0
  62. griptape_nodes/utils/path_utils.py +58 -0
  63. griptape_nodes/utils/url_utils.py +106 -0
  64. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.72.0.dist-info}/METADATA +2 -1
  65. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.72.0.dist-info}/RECORD +67 -52
  66. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.72.0.dist-info}/WHEEL +1 -1
  67. {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.72.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,55 @@
1
+ """Registry for image metadata injection drivers."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from griptape_nodes.drivers.image_metadata.base_image_metadata_driver import BaseImageMetadataDriver
6
+ from griptape_nodes.utils.metaclasses import SingletonMeta
7
+
8
+
9
+ class ImageMetadataDriverRegistry(metaclass=SingletonMeta):
10
+ """Registry for image metadata injection drivers.
11
+
12
+ Provides centralized registration and lookup of metadata injection drivers
13
+ based on image format. Follows singleton pattern to ensure single registry instance.
14
+ """
15
+
16
+ _drivers: ClassVar[list[BaseImageMetadataDriver]] = []
17
+
18
+ @classmethod
19
+ def register_driver(cls, driver: BaseImageMetadataDriver) -> None:
20
+ """Register a metadata injection driver.
21
+
22
+ Args:
23
+ driver: Driver instance to register
24
+ """
25
+ instance = cls()
26
+ instance._drivers.append(driver)
27
+
28
+ @classmethod
29
+ def get_driver_for_format(cls, format_str: str) -> BaseImageMetadataDriver | None:
30
+ """Get the first driver that supports the given format.
31
+
32
+ Args:
33
+ format_str: PIL format string (e.g., "PNG", "JPEG")
34
+
35
+ Returns:
36
+ Driver instance or None if no driver supports format
37
+ """
38
+ instance = cls()
39
+ for driver in instance._drivers:
40
+ if format_str in driver.get_supported_formats():
41
+ return driver
42
+ return None
43
+
44
+ @classmethod
45
+ def get_supported_formats(cls) -> set[str]:
46
+ """Get all formats supported by registered drivers.
47
+
48
+ Returns:
49
+ Set of format strings supported by any registered driver
50
+ """
51
+ instance = cls()
52
+ formats = set()
53
+ for driver in instance._drivers:
54
+ formats.update(driver.get_supported_formats())
55
+ return formats
@@ -0,0 +1,71 @@
1
+ """PNG metadata injection and extraction driver using text chunks."""
2
+
3
+ from io import BytesIO
4
+
5
+ from PIL import Image, PngImagePlugin
6
+
7
+ from griptape_nodes.drivers.image_metadata.base_image_metadata_driver import BaseImageMetadataDriver
8
+
9
+
10
+ class PngMetadataDriver(BaseImageMetadataDriver):
11
+ """Bidirectional driver for PNG metadata using text chunks.
12
+
13
+ Supports both reading and writing metadata in PNG text chunks.
14
+ Preserves existing text chunks when writing new metadata.
15
+ All PNG-specific logic is encapsulated in this driver.
16
+ """
17
+
18
+ def get_supported_formats(self) -> list[str]:
19
+ """Return list of PIL format strings this driver supports.
20
+
21
+ Returns:
22
+ List containing "PNG"
23
+ """
24
+ return ["PNG"]
25
+
26
+ def inject_metadata(self, pil_image: Image.Image, metadata: dict[str, str]) -> bytes:
27
+ """Inject metadata into PNG text chunks.
28
+
29
+ Creates PngInfo with existing text chunks and adds new metadata.
30
+ New metadata overwrites existing keys with same name.
31
+
32
+ Args:
33
+ pil_image: PIL Image to inject metadata into
34
+ metadata: Dictionary of key-value pairs to inject
35
+
36
+ Returns:
37
+ Image bytes with metadata injected
38
+
39
+ Raises:
40
+ Exception: On PNG save errors
41
+ """
42
+ # Create PNG info object
43
+ png_info = PngImagePlugin.PngInfo()
44
+
45
+ # Preserve existing text chunks
46
+ for key, value in pil_image.info.items():
47
+ # Only preserve string key-value pairs (text chunks), skip binary data
48
+ # Don't preserve keys that will be overwritten by new metadata
49
+ if isinstance(key, str) and isinstance(value, str) and key not in metadata:
50
+ png_info.add_text(key, value)
51
+
52
+ # Add new metadata
53
+ for key, value in metadata.items():
54
+ png_info.add_text(key, str(value))
55
+
56
+ # Save with metadata
57
+ output_buffer = BytesIO()
58
+ pil_image.save(output_buffer, format="PNG", pnginfo=png_info)
59
+ return output_buffer.getvalue()
60
+
61
+ def extract_metadata(self, pil_image: Image.Image) -> dict[str, str]:
62
+ """Extract all text chunks from PNG image.
63
+
64
+ Args:
65
+ pil_image: PIL Image to extract metadata from
66
+
67
+ Returns:
68
+ Dictionary of all PNG text chunks, empty dict if none found
69
+ """
70
+ # PIL unpacks PNG text chunks directly into pil_image.info
71
+ return {key: value for key, value in pil_image.info.items() if isinstance(key, str) and isinstance(value, str)}
@@ -75,6 +75,38 @@ class BaseStorageDriver(ABC):
75
75
  """
76
76
  ...
77
77
 
78
+ @abstractmethod
79
+ def get_asset_url(self, path: Path) -> str:
80
+ """Get the permanent unsigned URL for an asset.
81
+
82
+ Args:
83
+ path: The path of the file
84
+
85
+ Returns:
86
+ Permanent URL for accessing the asset
87
+ """
88
+ ...
89
+
90
+ @abstractmethod
91
+ def save_file(
92
+ self, path: Path, file_content: bytes, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
93
+ ) -> str:
94
+ """Save a file to storage.
95
+
96
+ Args:
97
+ path: The path of the file to save.
98
+ file_content: The file content as bytes.
99
+ existing_file_policy: How to handle existing files. Defaults to OVERWRITE.
100
+
101
+ Returns:
102
+ The absolute file path where the file was saved.
103
+
104
+ Raises:
105
+ FileExistsError: When existing_file_policy is FAIL and file already exists.
106
+ RuntimeError: If file save fails.
107
+ """
108
+ ...
109
+
78
110
  def upload_file(
79
111
  self, path: Path, file_content: bytes, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
80
112
  ) -> str:
@@ -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