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.
- griptape_nodes/api_client/client.py +8 -5
- griptape_nodes/app/app.py +4 -0
- griptape_nodes/bootstrap/utils/python_subprocess_executor.py +48 -9
- griptape_nodes/bootstrap/utils/subprocess_websocket_base.py +88 -0
- griptape_nodes/bootstrap/utils/subprocess_websocket_listener.py +126 -0
- griptape_nodes/bootstrap/utils/subprocess_websocket_sender.py +121 -0
- griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +17 -170
- griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +10 -1
- griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +13 -117
- griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +4 -0
- griptape_nodes/bootstrap/workflow_publishers/local_session_workflow_publisher.py +206 -0
- griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +22 -3
- griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +49 -25
- griptape_nodes/common/node_executor.py +61 -14
- 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/base_iterative_nodes.py +1 -1
- griptape_nodes/exe_types/node_groups/base_node_group.py +3 -0
- griptape_nodes/exe_types/node_groups/subflow_node_group.py +18 -0
- griptape_nodes/exe_types/node_types.py +13 -0
- griptape_nodes/exe_types/param_components/log_parameter.py +4 -4
- griptape_nodes/exe_types/param_components/subflow_execution_component.py +329 -0
- griptape_nodes/exe_types/param_types/parameter_audio.py +17 -2
- griptape_nodes/exe_types/param_types/parameter_float.py +4 -4
- griptape_nodes/exe_types/param_types/parameter_image.py +14 -1
- griptape_nodes/exe_types/param_types/parameter_int.py +4 -4
- griptape_nodes/exe_types/param_types/parameter_number.py +12 -14
- griptape_nodes/exe_types/param_types/parameter_three_d.py +14 -1
- griptape_nodes/exe_types/param_types/parameter_video.py +17 -2
- 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 +270 -33
- 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/utils/__init__.py +9 -1
- griptape_nodes/utils/artifact_normalization.py +245 -0
- griptape_nodes/utils/file_utils.py +13 -13
- griptape_nodes/utils/http_file_patch.py +613 -0
- griptape_nodes/utils/image_preview.py +27 -0
- griptape_nodes/utils/path_utils.py +58 -0
- griptape_nodes/utils/url_utils.py +106 -0
- {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.72.0.dist-info}/METADATA +2 -1
- {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.72.0.dist-info}/RECORD +67 -52
- {griptape_nodes-0.70.1.dist-info → griptape_nodes-0.72.0.dist-info}/WHEEL +1 -1
- {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
|
|