griptape-nodes 0.71.0__py3-none-any.whl → 0.72.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. griptape_nodes/app/app.py +4 -0
  2. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +10 -1
  3. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +4 -0
  4. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +4 -0
  5. griptape_nodes/common/node_executor.py +1 -1
  6. griptape_nodes/drivers/image_metadata/__init__.py +21 -0
  7. griptape_nodes/drivers/image_metadata/base_image_metadata_driver.py +63 -0
  8. griptape_nodes/drivers/image_metadata/exif_metadata_driver.py +218 -0
  9. griptape_nodes/drivers/image_metadata/image_metadata_driver_registry.py +55 -0
  10. griptape_nodes/drivers/image_metadata/png_metadata_driver.py +71 -0
  11. griptape_nodes/drivers/storage/base_storage_driver.py +32 -0
  12. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +384 -10
  13. griptape_nodes/drivers/storage/local_storage_driver.py +65 -4
  14. griptape_nodes/drivers/thread_storage/local_thread_storage_driver.py +1 -0
  15. griptape_nodes/exe_types/node_groups/base_node_group.py +3 -0
  16. griptape_nodes/exe_types/node_types.py +13 -0
  17. griptape_nodes/exe_types/param_components/log_parameter.py +3 -2
  18. griptape_nodes/exe_types/param_types/parameter_float.py +4 -4
  19. griptape_nodes/exe_types/param_types/parameter_int.py +4 -4
  20. griptape_nodes/exe_types/param_types/parameter_number.py +34 -30
  21. griptape_nodes/node_library/workflow_registry.py +5 -8
  22. griptape_nodes/retained_mode/events/app_events.py +1 -0
  23. griptape_nodes/retained_mode/events/base_events.py +42 -26
  24. griptape_nodes/retained_mode/events/flow_events.py +67 -0
  25. griptape_nodes/retained_mode/events/library_events.py +1 -1
  26. griptape_nodes/retained_mode/events/node_events.py +1 -0
  27. griptape_nodes/retained_mode/events/os_events.py +22 -0
  28. griptape_nodes/retained_mode/events/static_file_events.py +28 -4
  29. griptape_nodes/retained_mode/managers/flow_manager.py +134 -0
  30. griptape_nodes/retained_mode/managers/image_metadata_injector.py +339 -0
  31. griptape_nodes/retained_mode/managers/library_manager.py +71 -41
  32. griptape_nodes/retained_mode/managers/model_manager.py +1 -0
  33. griptape_nodes/retained_mode/managers/node_manager.py +8 -5
  34. griptape_nodes/retained_mode/managers/os_manager.py +269 -32
  35. griptape_nodes/retained_mode/managers/project_manager.py +3 -7
  36. griptape_nodes/retained_mode/managers/session_manager.py +1 -0
  37. griptape_nodes/retained_mode/managers/settings.py +5 -0
  38. griptape_nodes/retained_mode/managers/static_files_manager.py +83 -17
  39. griptape_nodes/retained_mode/managers/workflow_manager.py +71 -41
  40. griptape_nodes/servers/static.py +34 -0
  41. griptape_nodes/traits/clamp.py +52 -9
  42. griptape_nodes/utils/__init__.py +9 -1
  43. griptape_nodes/utils/file_utils.py +13 -13
  44. griptape_nodes/utils/http_file_patch.py +613 -0
  45. griptape_nodes/utils/path_utils.py +58 -0
  46. griptape_nodes/utils/url_utils.py +106 -0
  47. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.1.dist-info}/METADATA +2 -1
  48. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.1.dist-info}/RECORD +50 -41
  49. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.1.dist-info}/WHEEL +1 -1
  50. {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.1.dist-info}/entry_points.txt +0 -0
@@ -101,7 +101,7 @@ def find_all_files_in_directory(directory: Path, pattern: str) -> list[Path]:
101
101
  return matches
102
102
 
103
103
 
104
- def find_files_recursive(directory: Path, pattern: str, *, skip_hidden: bool = True) -> set[Path]:
104
+ def find_files_recursive(directory: Path, pattern: str, *, skip_hidden: bool = True) -> list[Path]:
105
105
  """Search directory recursively for files matching pattern.
106
106
 
107
107
  Args:
@@ -111,38 +111,38 @@ def find_files_recursive(directory: Path, pattern: str, *, skip_hidden: bool = T
111
111
  This is more efficient when dealing with large hidden directories like .git, .venv, etc.
112
112
 
113
113
  Returns:
114
- Set of all matching file paths. Returns empty set if none found.
114
+ Sorted list of all matching file paths. Returns empty list if none found.
115
115
 
116
116
  Examples:
117
117
  >>> find_files_recursive(Path("/workspace"), "*.json")
118
- {Path("/workspace/a.json"), Path("/workspace/sub/b.json")}
118
+ [Path("/workspace/a.json"), Path("/workspace/sub/b.json")]
119
119
  >>> find_files_recursive(Path("/workspace"), "*.json", skip_hidden=False)
120
- {Path("/workspace/a.json"), Path("/workspace/.config/b.json")}
120
+ [Path("/workspace/.config/b.json"), Path("/workspace/a.json")]
121
121
  >>> find_files_recursive(Path("/empty"), "*.txt")
122
- set()
122
+ []
123
123
  """
124
124
  if not directory.exists():
125
125
  logger.debug("Directory does not exist: %s", directory)
126
- return set()
126
+ return []
127
127
 
128
128
  if not directory.is_dir():
129
129
  logger.debug("Path is not a directory: %s", directory)
130
- return set()
130
+ return []
131
131
 
132
- def _recurse(path: Path) -> set[Path]:
132
+ def _recurse(path: Path) -> list[Path]:
133
133
  """Recursively find files."""
134
- results = set()
134
+ results = []
135
135
  try:
136
- for item in path.iterdir():
136
+ for item in sorted(path.iterdir()):
137
137
  # Skip hidden files/directories if requested
138
138
  if skip_hidden and item.name.startswith("."):
139
139
  continue
140
140
 
141
141
  if item.is_file() and fnmatch(item.name, pattern):
142
- results.add(item)
142
+ results.append(item)
143
143
  elif item.is_dir():
144
144
  # Recurse into directories
145
- results.update(_recurse(item))
145
+ results.extend(_recurse(item))
146
146
  except (PermissionError, OSError) as e:
147
147
  # Skip directories we can't access
148
148
  logger.debug("Cannot access directory %s: %s", path, e)
@@ -156,4 +156,4 @@ def find_files_recursive(directory: Path, pattern: str, *, skip_hidden: bool = T
156
156
  else:
157
157
  logger.debug("Found %d file(s) matching pattern '%s' in directory: %s", len(matches), pattern, directory)
158
158
 
159
- return matches
159
+ return sorted(matches)
@@ -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")