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.
- griptape_nodes/app/app.py +4 -0
- griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +10 -1
- griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +4 -0
- griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +4 -0
- griptape_nodes/common/node_executor.py +1 -1
- 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/node_groups/base_node_group.py +3 -0
- griptape_nodes/exe_types/node_types.py +13 -0
- griptape_nodes/exe_types/param_components/log_parameter.py +3 -2
- griptape_nodes/exe_types/param_types/parameter_float.py +4 -4
- griptape_nodes/exe_types/param_types/parameter_int.py +4 -4
- griptape_nodes/exe_types/param_types/parameter_number.py +34 -30
- 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 +269 -32
- 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 +34 -0
- griptape_nodes/traits/clamp.py +52 -9
- griptape_nodes/utils/__init__.py +9 -1
- griptape_nodes/utils/file_utils.py +13 -13
- griptape_nodes/utils/http_file_patch.py +613 -0
- griptape_nodes/utils/path_utils.py +58 -0
- griptape_nodes/utils/url_utils.py +106 -0
- {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.1.dist-info}/METADATA +2 -1
- {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.1.dist-info}/RECORD +50 -41
- {griptape_nodes-0.71.0.dist-info → griptape_nodes-0.72.1.dist-info}/WHEEL +1 -1
- {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) ->
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
[Path("/workspace/a.json"), Path("/workspace/sub/b.json")]
|
|
119
119
|
>>> find_files_recursive(Path("/workspace"), "*.json", skip_hidden=False)
|
|
120
|
-
|
|
120
|
+
[Path("/workspace/.config/b.json"), Path("/workspace/a.json")]
|
|
121
121
|
>>> find_files_recursive(Path("/empty"), "*.txt")
|
|
122
|
-
|
|
122
|
+
[]
|
|
123
123
|
"""
|
|
124
124
|
if not directory.exists():
|
|
125
125
|
logger.debug("Directory does not exist: %s", directory)
|
|
126
|
-
return
|
|
126
|
+
return []
|
|
127
127
|
|
|
128
128
|
if not directory.is_dir():
|
|
129
129
|
logger.debug("Path is not a directory: %s", directory)
|
|
130
|
-
return
|
|
130
|
+
return []
|
|
131
131
|
|
|
132
|
-
def _recurse(path: Path) ->
|
|
132
|
+
def _recurse(path: Path) -> list[Path]:
|
|
133
133
|
"""Recursively find files."""
|
|
134
|
-
results =
|
|
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.
|
|
142
|
+
results.append(item)
|
|
143
143
|
elif item.is_dir():
|
|
144
144
|
# Recurse into directories
|
|
145
|
-
results.
|
|
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")
|