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,613 @@
1
+ r"""Monkey-patch httpx and requests to transparently handle file:// URLs, local file paths, and cloud assets.
2
+
3
+ This module patches httpx and requests libraries at runtime to support:
4
+ - file:// URLs
5
+ - Absolute local file paths (e.g., /path/to/file.txt, C:\path\to\file.txt)
6
+ - Network paths (UNC paths like \\server\share\file.txt, if accessible)
7
+ - Griptape Cloud asset URLs (automatically converted to signed download URLs)
8
+
9
+ File operations are mapped to HTTP-like responses for seamless integration with
10
+ existing code. HTTP/HTTPS/FTP URLs are fast-pathed to avoid filesystem checks.
11
+ Cloud asset URLs matching pattern /buckets/{id}/assets/{path} are automatically
12
+ converted to presigned download URLs when credentials are available.
13
+ """
14
+
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any
18
+ from urllib.parse import urlparse
19
+ from urllib.request import url2pathname
20
+
21
+ import httpx
22
+ import requests
23
+
24
+ from griptape_nodes.utils.url_utils import get_content_type_from_extension
25
+
26
+ logger = logging.getLogger("griptape_nodes")
27
+
28
+ # HTTP status code constants
29
+ HTTP_OK = 200
30
+ HTTP_MULTIPLE_CHOICES = 300
31
+ HTTP_BAD_REQUEST = 400
32
+ HTTP_FORBIDDEN = 403
33
+ HTTP_NOT_FOUND = 404
34
+ HTTP_INTERNAL_SERVER_ERROR = 500
35
+ HTTP_ERROR_THRESHOLD = 600
36
+
37
+ # Store original functions to delegate non-file:// URLs
38
+ _original_httpx_request: Any = None
39
+ _original_httpx_get: Any = None
40
+ _original_httpx_post: Any = None
41
+ _original_httpx_put: Any = None
42
+ _original_httpx_delete: Any = None
43
+ _original_httpx_patch: Any = None
44
+ _original_requests_get: Any = None
45
+
46
+ # Store original httpx.Client instance methods
47
+ _original_httpx_client_request: Any = None
48
+ _original_httpx_client_get: Any = None
49
+ _original_httpx_client_post: Any = None
50
+ _original_httpx_client_put: Any = None
51
+ _original_httpx_client_delete: Any = None
52
+ _original_httpx_client_patch: Any = None
53
+
54
+ # Store original httpx.AsyncClient instance methods
55
+ _original_httpx_async_client_request: Any = None
56
+ _original_httpx_async_client_get: Any = None
57
+ _original_httpx_async_client_post: Any = None
58
+ _original_httpx_async_client_put: Any = None
59
+ _original_httpx_async_client_delete: Any = None
60
+ _original_httpx_async_client_patch: Any = None
61
+
62
+ _patches_installed = False
63
+
64
+
65
+ def _is_http_url(url_str: str) -> bool:
66
+ """Quick check for http/https/ftp URLs to bypass filesystem checks."""
67
+ return url_str.startswith(("http://", "https://", "ftp://", "ftps://"))
68
+
69
+
70
+ def _is_local_file_path(url_str: str) -> bool:
71
+ """Check if string is an absolute file path that exists.
72
+
73
+ Excludes URLs that already have schemes (file://, http://, etc.).
74
+ Network paths (UNC) are allowed if they exist and are accessible.
75
+
76
+ Args:
77
+ url_str: String to check
78
+
79
+ Returns:
80
+ True if url_str is an absolute file path that exists
81
+ """
82
+ # Exclude URLs with schemes - handle them via existing logic
83
+ if "://" in url_str:
84
+ return False
85
+
86
+ # Check if absolute path that exists
87
+ try:
88
+ path = Path(url_str)
89
+ return path.is_absolute() and path.exists()
90
+ except (ValueError, OSError):
91
+ return False
92
+
93
+
94
+ class FileHttpxResponse:
95
+ """Response wrapper that mimics httpx.Response interface for file:// URLs."""
96
+
97
+ def __init__(self, content: bytes, status_code: int, file_path: str):
98
+ """Initialize file response.
99
+
100
+ Args:
101
+ content: File content as bytes
102
+ status_code: HTTP status code (200 for success, 404/403/etc for errors)
103
+ file_path: Path to the file for MIME type detection
104
+ """
105
+ self.content = content
106
+ self.status_code = status_code
107
+ self._file_path = file_path
108
+
109
+ # Build headers dict
110
+ headers_dict = {}
111
+ if status_code == HTTP_OK:
112
+ headers_dict["Content-Length"] = str(len(content))
113
+ content_type = get_content_type_from_extension(file_path)
114
+ if content_type:
115
+ headers_dict["Content-Type"] = content_type
116
+
117
+ self.headers = headers_dict
118
+
119
+ @property
120
+ def text(self) -> str:
121
+ """Return content as text string."""
122
+ return self.content.decode("utf-8", errors="replace")
123
+
124
+ def raise_for_status(self) -> None:
125
+ """Raise HTTPStatusError for error status codes (4xx, 5xx)."""
126
+ if HTTP_BAD_REQUEST <= self.status_code < HTTP_ERROR_THRESHOLD:
127
+ msg = f"File error: {self.status_code} for file:// URL: {self._file_path}"
128
+ # Create a minimal request object for the exception
129
+ request = httpx.Request("GET", self._file_path)
130
+ raise httpx.HTTPStatusError(msg, request=request, response=self) # type: ignore[arg-type]
131
+
132
+ def json(self) -> Any:
133
+ """Parse content as JSON."""
134
+ import json
135
+
136
+ return json.loads(self.text)
137
+
138
+
139
+ class FileRequestsResponse:
140
+ """Response wrapper that mimics requests.Response interface for file:// URLs."""
141
+
142
+ def __init__(self, content: bytes, status_code: int, file_path: str):
143
+ """Initialize file response.
144
+
145
+ Args:
146
+ content: File content as bytes
147
+ status_code: HTTP status code (200 for success, 404/403/etc for errors)
148
+ file_path: Path to the file for MIME type detection
149
+ """
150
+ self.content = content
151
+ self.status_code = status_code
152
+ self._file_path = file_path
153
+
154
+ # Build headers dict
155
+ headers_dict = {}
156
+ if status_code == HTTP_OK:
157
+ headers_dict["Content-Length"] = str(len(content))
158
+ content_type = get_content_type_from_extension(file_path)
159
+ if content_type:
160
+ headers_dict["Content-Type"] = content_type
161
+
162
+ self.headers = headers_dict
163
+ self.ok = HTTP_OK <= status_code < HTTP_MULTIPLE_CHOICES
164
+
165
+ @property
166
+ def text(self) -> str:
167
+ """Return content as text string."""
168
+ return self.content.decode("utf-8", errors="replace")
169
+
170
+ def raise_for_status(self) -> None:
171
+ """Raise HTTPError for error status codes (4xx, 5xx)."""
172
+ if HTTP_BAD_REQUEST <= self.status_code < HTTP_ERROR_THRESHOLD:
173
+ msg = f"File error: {self.status_code} for file:// URL: {self._file_path}"
174
+ raise requests.HTTPError(msg, response=self) # type: ignore[arg-type]
175
+
176
+ def json(self) -> Any:
177
+ """Parse content as JSON."""
178
+ import json
179
+
180
+ return json.loads(self.text)
181
+
182
+
183
+ def _handle_file_url(url: str, *, response_type: type) -> FileHttpxResponse | FileRequestsResponse:
184
+ """Handle file:// URL by reading local file and returning HTTP-like response.
185
+
186
+ Args:
187
+ url: file:// URL to handle
188
+ response_type: Response class to instantiate (FileHttpxResponse or FileRequestsResponse)
189
+
190
+ Returns:
191
+ Response wrapper with file content or error status
192
+ """
193
+ # Validate input
194
+ if not url.startswith("file://"):
195
+ return response_type(
196
+ content=b"",
197
+ status_code=HTTP_BAD_REQUEST,
198
+ file_path=url,
199
+ )
200
+
201
+ # Extract file path from file:// URL (same pattern as static_files_manager.py:193-196)
202
+ parsed = urlparse(url)
203
+ file_path_str = url2pathname(parsed.path)
204
+ file_path = Path(file_path_str)
205
+
206
+ # Check if file exists
207
+ if not file_path.exists():
208
+ error_msg = f"File not found: {file_path_str}"
209
+ logger.debug(error_msg)
210
+ return response_type(
211
+ content=error_msg.encode("utf-8"),
212
+ status_code=HTTP_NOT_FOUND,
213
+ file_path=file_path_str,
214
+ )
215
+
216
+ # Check if path is a directory
217
+ if file_path.is_dir():
218
+ error_msg = f"Path is a directory, not a file: {file_path_str}"
219
+ logger.debug(error_msg)
220
+ return response_type(
221
+ content=error_msg.encode("utf-8"),
222
+ status_code=HTTP_BAD_REQUEST,
223
+ file_path=file_path_str,
224
+ )
225
+
226
+ # Try to read file
227
+ try:
228
+ content = file_path.read_bytes()
229
+ except PermissionError:
230
+ error_msg = f"Permission denied: {file_path_str}"
231
+ logger.debug(error_msg)
232
+ return response_type(
233
+ content=error_msg.encode("utf-8"),
234
+ status_code=HTTP_FORBIDDEN,
235
+ file_path=file_path_str,
236
+ )
237
+ except OSError as e:
238
+ error_msg = f"Error reading file: {file_path_str}: {e}"
239
+ logger.debug(error_msg)
240
+ return response_type(
241
+ content=error_msg.encode("utf-8"),
242
+ status_code=HTTP_INTERNAL_SERVER_ERROR,
243
+ file_path=file_path_str,
244
+ )
245
+
246
+ # Success - return file content
247
+ return response_type(
248
+ content=content,
249
+ status_code=HTTP_OK,
250
+ file_path=file_path_str,
251
+ )
252
+
253
+
254
+ def _patched_httpx_request(method: str, url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
255
+ """Patched httpx.request that handles file:// URLs, local file paths, and cloud asset URLs.
256
+
257
+ Args:
258
+ method: HTTP method (GET, POST, etc.)
259
+ url: URL to request (file://, http://, https://, cloud asset, etc.) or absolute file path
260
+ **kwargs: Additional arguments for httpx.request
261
+
262
+ Returns:
263
+ httpx.Response or FileHttpxResponse
264
+ """
265
+ # Lazy import to avoid circular dependency: utils/__init__.py -> http_file_patch -> storage drivers -> os_events -> payload_registry
266
+ from griptape_nodes.drivers.storage.griptape_cloud_storage_driver import GriptapeCloudStorageDriver
267
+
268
+ # Convert httpx.URL to string for checking
269
+ url_str = str(url)
270
+
271
+ # Detect and convert cloud asset URLs to signed download URLs (GET only)
272
+ if method.upper() == "GET" and GriptapeCloudStorageDriver.is_cloud_asset_url(url_str):
273
+ signed_url = GriptapeCloudStorageDriver.create_signed_download_url_from_asset_url(
274
+ url_str, httpx_request_func=_original_httpx_request
275
+ )
276
+ if signed_url:
277
+ return _original_httpx_request(method, signed_url, **kwargs)
278
+ # If conversion failed, continue with original URL
279
+
280
+ # Fast path: Skip filesystem checks for HTTP/HTTPS/FTP URLs (99%+ of requests)
281
+ if _is_http_url(url_str):
282
+ return _original_httpx_request(method, url, **kwargs)
283
+
284
+ # Handle existing file:// URLs
285
+ if url_str.startswith("file://"):
286
+ return _handle_file_url(url_str, response_type=FileHttpxResponse) # type: ignore[return-value]
287
+
288
+ # Detect and convert local file paths
289
+ if _is_local_file_path(url_str):
290
+ file_url = str(Path(url_str).as_uri())
291
+ return _handle_file_url(file_url, response_type=FileHttpxResponse) # type: ignore[return-value]
292
+
293
+ # Delegate all other URLs to original httpx.request
294
+ return _original_httpx_request(method, url, **kwargs)
295
+
296
+
297
+ def _patched_httpx_get(url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
298
+ """Patched httpx.get that handles file:// URLs."""
299
+ return _patched_httpx_request("GET", url, **kwargs)
300
+
301
+
302
+ def _patched_httpx_post(url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
303
+ """Patched httpx.post that handles file:// URLs."""
304
+ return _patched_httpx_request("POST", url, **kwargs)
305
+
306
+
307
+ def _patched_httpx_put(url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
308
+ """Patched httpx.put that handles file:// URLs."""
309
+ return _patched_httpx_request("PUT", url, **kwargs)
310
+
311
+
312
+ def _patched_httpx_delete(url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
313
+ """Patched httpx.delete that handles file:// URLs."""
314
+ return _patched_httpx_request("DELETE", url, **kwargs)
315
+
316
+
317
+ def _patched_httpx_patch(url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
318
+ """Patched httpx.patch that handles file:// URLs."""
319
+ return _patched_httpx_request("PATCH", url, **kwargs)
320
+
321
+
322
+ def _patched_requests_get(url: str, **kwargs: Any) -> requests.Response | FileRequestsResponse:
323
+ """Patched requests.get that handles file:// URLs, local file paths, and cloud asset URLs.
324
+
325
+ Args:
326
+ url: URL to request (file://, http://, https://, cloud asset, etc.) or absolute file path
327
+ **kwargs: Additional arguments for requests.get
328
+
329
+ Returns:
330
+ requests.Response or FileRequestsResponse
331
+ """
332
+ # Lazy import to avoid circular dependency: utils/__init__.py -> http_file_patch -> storage drivers -> os_events -> payload_registry
333
+ from griptape_nodes.drivers.storage.griptape_cloud_storage_driver import GriptapeCloudStorageDriver
334
+
335
+ # Detect and convert cloud asset URLs to signed download URLs
336
+ if GriptapeCloudStorageDriver.is_cloud_asset_url(url):
337
+ signed_url = GriptapeCloudStorageDriver.create_signed_download_url_from_asset_url(
338
+ url, httpx_request_func=_original_httpx_request
339
+ )
340
+ if signed_url:
341
+ return _original_requests_get(signed_url, **kwargs)
342
+ # If conversion failed, continue with original URL
343
+
344
+ # Fast path: Skip filesystem checks for HTTP/HTTPS/FTP URLs (99%+ of requests)
345
+ if _is_http_url(url):
346
+ return _original_requests_get(url, **kwargs)
347
+
348
+ # Handle existing file:// URLs
349
+ if url.startswith("file://"):
350
+ return _handle_file_url(url, response_type=FileRequestsResponse) # type: ignore[return-value]
351
+
352
+ # Detect and convert local file paths
353
+ if _is_local_file_path(url):
354
+ file_url = str(Path(url).as_uri())
355
+ return _handle_file_url(file_url, response_type=FileRequestsResponse) # type: ignore[return-value]
356
+
357
+ # Delegate all other URLs to original requests.get
358
+ return _original_requests_get(url, **kwargs)
359
+
360
+
361
+ # ============================================================================
362
+ # httpx.Client instance method patches (sync)
363
+ # ============================================================================
364
+
365
+
366
+ def _patched_client_request(
367
+ self: Any, method: str, url: str | httpx.URL, **kwargs: Any
368
+ ) -> httpx.Response | FileHttpxResponse:
369
+ """Patched httpx.Client.request() that handles file:// URLs, local file paths, and cloud asset URLs.
370
+
371
+ Args:
372
+ self: The httpx.Client instance
373
+ method: HTTP method (GET, POST, etc.)
374
+ url: URL to request (file://, http://, https://, cloud asset, etc.) or absolute file path
375
+ **kwargs: Additional arguments for the request
376
+
377
+ Returns:
378
+ httpx.Response or FileHttpxResponse
379
+ """
380
+ # Lazy import to avoid circular dependency: utils/__init__.py -> http_file_patch -> storage drivers -> os_events -> payload_registry
381
+ from griptape_nodes.drivers.storage.griptape_cloud_storage_driver import GriptapeCloudStorageDriver
382
+
383
+ url_str = str(url)
384
+
385
+ # Cloud asset URL conversion (GET only)
386
+ if method.upper() == "GET" and GriptapeCloudStorageDriver.is_cloud_asset_url(url_str):
387
+ signed_url = GriptapeCloudStorageDriver.create_signed_download_url_from_asset_url(
388
+ url_str, httpx_request_func=_original_httpx_request
389
+ )
390
+ if signed_url:
391
+ return _original_httpx_client_request(self, method, signed_url, **kwargs)
392
+
393
+ # Fast path for HTTP/HTTPS/FTP
394
+ if _is_http_url(url_str):
395
+ return _original_httpx_client_request(self, method, url, **kwargs)
396
+
397
+ # Handle file:// URLs
398
+ if url_str.startswith("file://"):
399
+ return _handle_file_url(url_str, response_type=FileHttpxResponse) # type: ignore[return-value]
400
+
401
+ # Handle local file paths
402
+ if _is_local_file_path(url_str):
403
+ file_url = str(Path(url_str).as_uri())
404
+ return _handle_file_url(file_url, response_type=FileHttpxResponse) # type: ignore[return-value]
405
+
406
+ # Delegate to original
407
+ return _original_httpx_client_request(self, method, url, **kwargs)
408
+
409
+
410
+ def _patched_client_get(self: Any, url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
411
+ """Patched httpx.Client.get() for file:// and cloud URLs."""
412
+ return _patched_client_request(self, "GET", url, **kwargs)
413
+
414
+
415
+ def _patched_client_post(self: Any, url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
416
+ """Patched httpx.Client.post() for file:// and cloud URLs."""
417
+ return _patched_client_request(self, "POST", url, **kwargs)
418
+
419
+
420
+ def _patched_client_put(self: Any, url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
421
+ """Patched httpx.Client.put() for file:// and cloud URLs."""
422
+ return _patched_client_request(self, "PUT", url, **kwargs)
423
+
424
+
425
+ def _patched_client_delete(self: Any, url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
426
+ """Patched httpx.Client.delete() for file:// and cloud URLs."""
427
+ return _patched_client_request(self, "DELETE", url, **kwargs)
428
+
429
+
430
+ def _patched_client_patch(self: Any, url: str | httpx.URL, **kwargs: Any) -> httpx.Response | FileHttpxResponse:
431
+ """Patched httpx.Client.patch() for file:// and cloud URLs."""
432
+ return _patched_client_request(self, "PATCH", url, **kwargs)
433
+
434
+
435
+ # ============================================================================
436
+ # httpx.AsyncClient instance method patches (async)
437
+ # ============================================================================
438
+
439
+
440
+ async def _patched_async_client_request(
441
+ self: Any, method: str, url: str | httpx.URL, **kwargs: Any
442
+ ) -> httpx.Response | FileHttpxResponse:
443
+ """Patched httpx.AsyncClient.request() that handles file:// URLs, local file paths, and cloud asset URLs.
444
+
445
+ Args:
446
+ self: The httpx.AsyncClient instance
447
+ method: HTTP method (GET, POST, etc.)
448
+ url: URL to request (file://, http://, https://, cloud asset, etc.) or absolute file path
449
+ **kwargs: Additional arguments for the request
450
+
451
+ Returns:
452
+ httpx.Response or FileHttpxResponse
453
+ """
454
+ # Lazy import to avoid circular dependency: utils/__init__.py -> http_file_patch -> storage drivers -> os_events -> payload_registry
455
+ from griptape_nodes.drivers.storage.griptape_cloud_storage_driver import GriptapeCloudStorageDriver
456
+
457
+ url_str = str(url)
458
+
459
+ # Cloud asset URL conversion (GET only)
460
+ if method.upper() == "GET" and GriptapeCloudStorageDriver.is_cloud_asset_url(url_str):
461
+ signed_url = GriptapeCloudStorageDriver.create_signed_download_url_from_asset_url(
462
+ url_str, httpx_request_func=_original_httpx_request
463
+ )
464
+ if signed_url:
465
+ return await _original_httpx_async_client_request(self, method, signed_url, **kwargs)
466
+
467
+ # Fast path for HTTP/HTTPS/FTP
468
+ if _is_http_url(url_str):
469
+ return await _original_httpx_async_client_request(self, method, url, **kwargs)
470
+
471
+ # Handle file:// URLs (synchronous file read is fine in async context)
472
+ if url_str.startswith("file://"):
473
+ return _handle_file_url(url_str, response_type=FileHttpxResponse) # type: ignore[return-value]
474
+
475
+ # Handle local file paths
476
+ if _is_local_file_path(url_str):
477
+ file_url = str(Path(url_str).as_uri())
478
+ return _handle_file_url(file_url, response_type=FileHttpxResponse) # type: ignore[return-value]
479
+
480
+ # Delegate to original
481
+ return await _original_httpx_async_client_request(self, method, url, **kwargs)
482
+
483
+
484
+ async def _patched_async_client_get(
485
+ self: Any, url: str | httpx.URL, **kwargs: Any
486
+ ) -> httpx.Response | FileHttpxResponse:
487
+ """Patched httpx.AsyncClient.get() for file:// and cloud URLs."""
488
+ return await _patched_async_client_request(self, "GET", url, **kwargs)
489
+
490
+
491
+ async def _patched_async_client_post(
492
+ self: Any, url: str | httpx.URL, **kwargs: Any
493
+ ) -> httpx.Response | FileHttpxResponse:
494
+ """Patched httpx.AsyncClient.post() for file:// and cloud URLs."""
495
+ return await _patched_async_client_request(self, "POST", url, **kwargs)
496
+
497
+
498
+ async def _patched_async_client_put(
499
+ self: Any, url: str | httpx.URL, **kwargs: Any
500
+ ) -> httpx.Response | FileHttpxResponse:
501
+ """Patched httpx.AsyncClient.put() for file:// and cloud URLs."""
502
+ return await _patched_async_client_request(self, "PUT", url, **kwargs)
503
+
504
+
505
+ async def _patched_async_client_delete(
506
+ self: Any, url: str | httpx.URL, **kwargs: Any
507
+ ) -> httpx.Response | FileHttpxResponse:
508
+ """Patched httpx.AsyncClient.delete() for file:// and cloud URLs."""
509
+ return await _patched_async_client_request(self, "DELETE", url, **kwargs)
510
+
511
+
512
+ async def _patched_async_client_patch(
513
+ self: Any, url: str | httpx.URL, **kwargs: Any
514
+ ) -> httpx.Response | FileHttpxResponse:
515
+ """Patched httpx.AsyncClient.patch() for file:// and cloud URLs."""
516
+ return await _patched_async_client_request(self, "PATCH", url, **kwargs)
517
+
518
+
519
+ def _save_original_methods() -> None:
520
+ """Save original httpx and requests methods before patching."""
521
+ global _original_httpx_request # noqa: PLW0603
522
+ global _original_httpx_get # noqa: PLW0603
523
+ global _original_httpx_post # noqa: PLW0603
524
+ global _original_httpx_put # noqa: PLW0603
525
+ global _original_httpx_delete # noqa: PLW0603
526
+ global _original_httpx_patch # noqa: PLW0603
527
+ global _original_requests_get # noqa: PLW0603
528
+ global _original_httpx_client_request # noqa: PLW0603
529
+ global _original_httpx_client_get # noqa: PLW0603
530
+ global _original_httpx_client_post # noqa: PLW0603
531
+ global _original_httpx_client_put # noqa: PLW0603
532
+ global _original_httpx_client_delete # noqa: PLW0603
533
+ global _original_httpx_client_patch # noqa: PLW0603
534
+ global _original_httpx_async_client_request # noqa: PLW0603
535
+ global _original_httpx_async_client_get # noqa: PLW0603
536
+ global _original_httpx_async_client_post # noqa: PLW0603
537
+ global _original_httpx_async_client_put # noqa: PLW0603
538
+ global _original_httpx_async_client_delete # noqa: PLW0603
539
+ global _original_httpx_async_client_patch # noqa: PLW0603
540
+
541
+ # Save original module-level functions
542
+ _original_httpx_request = httpx.request
543
+ _original_httpx_get = httpx.get
544
+ _original_httpx_post = httpx.post
545
+ _original_httpx_put = httpx.put
546
+ _original_httpx_delete = httpx.delete
547
+ _original_httpx_patch = httpx.patch
548
+ _original_requests_get = requests.get
549
+
550
+ # Save original httpx.Client instance methods
551
+ _original_httpx_client_request = httpx.Client.request
552
+ _original_httpx_client_get = httpx.Client.get
553
+ _original_httpx_client_post = httpx.Client.post
554
+ _original_httpx_client_put = httpx.Client.put
555
+ _original_httpx_client_delete = httpx.Client.delete
556
+ _original_httpx_client_patch = httpx.Client.patch
557
+
558
+ # Save original httpx.AsyncClient instance methods
559
+ _original_httpx_async_client_request = httpx.AsyncClient.request
560
+ _original_httpx_async_client_get = httpx.AsyncClient.get
561
+ _original_httpx_async_client_post = httpx.AsyncClient.post
562
+ _original_httpx_async_client_put = httpx.AsyncClient.put
563
+ _original_httpx_async_client_delete = httpx.AsyncClient.delete
564
+ _original_httpx_async_client_patch = httpx.AsyncClient.patch
565
+
566
+
567
+ def _install_patches() -> None:
568
+ """Install patched methods to httpx and requests."""
569
+ # Install module-level patches
570
+ httpx.request = _patched_httpx_request # type: ignore[assignment]
571
+ httpx.get = _patched_httpx_get # type: ignore[assignment]
572
+ httpx.post = _patched_httpx_post # type: ignore[assignment]
573
+ httpx.put = _patched_httpx_put # type: ignore[assignment]
574
+ httpx.delete = _patched_httpx_delete # type: ignore[assignment]
575
+ httpx.patch = _patched_httpx_patch # type: ignore[assignment]
576
+ requests.get = _patched_requests_get # type: ignore[assignment]
577
+
578
+ # Install httpx.Client instance method patches
579
+ httpx.Client.request = _patched_client_request # type: ignore[method-assign]
580
+ httpx.Client.get = _patched_client_get # type: ignore[method-assign]
581
+ httpx.Client.post = _patched_client_post # type: ignore[method-assign]
582
+ httpx.Client.put = _patched_client_put # type: ignore[method-assign]
583
+ httpx.Client.delete = _patched_client_delete # type: ignore[method-assign]
584
+ httpx.Client.patch = _patched_client_patch # type: ignore[method-assign]
585
+
586
+ # Install httpx.AsyncClient instance method patches
587
+ httpx.AsyncClient.request = _patched_async_client_request # type: ignore[method-assign]
588
+ httpx.AsyncClient.get = _patched_async_client_get # type: ignore[method-assign]
589
+ httpx.AsyncClient.post = _patched_async_client_post # type: ignore[method-assign]
590
+ httpx.AsyncClient.put = _patched_async_client_put # type: ignore[method-assign]
591
+ httpx.AsyncClient.delete = _patched_async_client_delete # type: ignore[method-assign]
592
+ httpx.AsyncClient.patch = _patched_async_client_patch # type: ignore[method-assign]
593
+
594
+
595
+ def install_file_url_support() -> None:
596
+ """Install file:// URL support by patching httpx and requests at module level.
597
+
598
+ This should be called once at app initialization. Subsequent calls are no-ops.
599
+ """
600
+ global _patches_installed # noqa: PLW0603
601
+
602
+ # Prevent double-installation
603
+ if _patches_installed:
604
+ logger.debug("file:// URL support already installed, skipping")
605
+ return
606
+
607
+ logger.debug("Installing file:// URL support for httpx and requests")
608
+
609
+ _save_original_methods()
610
+ _install_patches()
611
+
612
+ _patches_installed = True
613
+ logger.debug("file:// URL support installed successfully")
@@ -24,6 +24,13 @@ def create_image_preview(
24
24
  Returns:
25
25
  Base64 encoded data URL of the preview, or None if failed
26
26
  """
27
+ # Check if it's an SVG file - PIL cannot open SVG files
28
+ # TODO: Add SVG support using cairosvg or similar library to rasterize SVG files: https://github.com/griptape-ai/griptape-nodes/issues/3721
29
+ # before creating previews
30
+ if image_path.suffix.lower() == ".svg":
31
+ logger.debug(f"SVG file detected, cannot create preview with PIL (vector graphics not supported): {image_path}")
32
+ return None
33
+
27
34
  try:
28
35
  # Open and resize the image
29
36
  with Image.open(image_path) as img:
@@ -53,6 +60,12 @@ def create_image_preview(
53
60
  return data_url
54
61
 
55
62
  except Exception as e:
63
+ # Check if error is due to SVG format
64
+ if "cannot identify image file" in str(e).lower() and image_path.suffix.lower() == ".svg":
65
+ logger.debug(
66
+ f"SVG file detected, cannot create preview with PIL (vector graphics not supported): {image_path}"
67
+ )
68
+ return None
56
69
  logger.warning(f"Failed to create preview for {image_path}: {e}")
57
70
  return None
58
71
 
@@ -73,6 +86,14 @@ def create_image_preview_from_bytes(
73
86
  Base64 encoded data URL of the preview, or None if failed
74
87
  """
75
88
  try:
89
+ # Check if content might be SVG by looking at first few bytes
90
+ # TODO: Add SVG support using cairosvg or similar library to rasterize SVG files: https://github.com/griptape-ai/griptape-nodes/issues/3721
91
+ # before creating previews
92
+ content_start = image_bytes[:100].decode("utf-8", errors="ignore").lower()
93
+ if "<svg" in content_start:
94
+ logger.debug("SVG file detected, cannot create preview with PIL (vector graphics not supported)")
95
+ return None
96
+
76
97
  # Open image from bytes
77
98
  with Image.open(io.BytesIO(image_bytes)) as img:
78
99
  # Convert to RGB if necessary (for WebP/JPEG output)
@@ -101,6 +122,12 @@ def create_image_preview_from_bytes(
101
122
  return data_url
102
123
 
103
124
  except Exception as e:
125
+ # Check if error is due to SVG format
126
+ if "cannot identify image file" in str(e).lower():
127
+ content_start = image_bytes[:100].decode("utf-8", errors="ignore").lower()
128
+ if "<svg" in content_start:
129
+ logger.debug("SVG file detected, cannot create preview with PIL (vector graphics not supported)")
130
+ return None
104
131
  logger.warning(f"Failed to create preview from bytes: {e}")
105
132
  return None
106
133