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,55 @@
|
|
|
1
|
+
"""Registry for image metadata injection drivers."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from griptape_nodes.drivers.image_metadata.base_image_metadata_driver import BaseImageMetadataDriver
|
|
6
|
+
from griptape_nodes.utils.metaclasses import SingletonMeta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ImageMetadataDriverRegistry(metaclass=SingletonMeta):
|
|
10
|
+
"""Registry for image metadata injection drivers.
|
|
11
|
+
|
|
12
|
+
Provides centralized registration and lookup of metadata injection drivers
|
|
13
|
+
based on image format. Follows singleton pattern to ensure single registry instance.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_drivers: ClassVar[list[BaseImageMetadataDriver]] = []
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def register_driver(cls, driver: BaseImageMetadataDriver) -> None:
|
|
20
|
+
"""Register a metadata injection driver.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
driver: Driver instance to register
|
|
24
|
+
"""
|
|
25
|
+
instance = cls()
|
|
26
|
+
instance._drivers.append(driver)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_driver_for_format(cls, format_str: str) -> BaseImageMetadataDriver | None:
|
|
30
|
+
"""Get the first driver that supports the given format.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
format_str: PIL format string (e.g., "PNG", "JPEG")
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Driver instance or None if no driver supports format
|
|
37
|
+
"""
|
|
38
|
+
instance = cls()
|
|
39
|
+
for driver in instance._drivers:
|
|
40
|
+
if format_str in driver.get_supported_formats():
|
|
41
|
+
return driver
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def get_supported_formats(cls) -> set[str]:
|
|
46
|
+
"""Get all formats supported by registered drivers.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Set of format strings supported by any registered driver
|
|
50
|
+
"""
|
|
51
|
+
instance = cls()
|
|
52
|
+
formats = set()
|
|
53
|
+
for driver in instance._drivers:
|
|
54
|
+
formats.update(driver.get_supported_formats())
|
|
55
|
+
return formats
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""PNG metadata injection and extraction driver using text chunks."""
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
|
|
5
|
+
from PIL import Image, PngImagePlugin
|
|
6
|
+
|
|
7
|
+
from griptape_nodes.drivers.image_metadata.base_image_metadata_driver import BaseImageMetadataDriver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PngMetadataDriver(BaseImageMetadataDriver):
|
|
11
|
+
"""Bidirectional driver for PNG metadata using text chunks.
|
|
12
|
+
|
|
13
|
+
Supports both reading and writing metadata in PNG text chunks.
|
|
14
|
+
Preserves existing text chunks when writing new metadata.
|
|
15
|
+
All PNG-specific logic is encapsulated in this driver.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def get_supported_formats(self) -> list[str]:
|
|
19
|
+
"""Return list of PIL format strings this driver supports.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List containing "PNG"
|
|
23
|
+
"""
|
|
24
|
+
return ["PNG"]
|
|
25
|
+
|
|
26
|
+
def inject_metadata(self, pil_image: Image.Image, metadata: dict[str, str]) -> bytes:
|
|
27
|
+
"""Inject metadata into PNG text chunks.
|
|
28
|
+
|
|
29
|
+
Creates PngInfo with existing text chunks and adds new metadata.
|
|
30
|
+
New metadata overwrites existing keys with same name.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
pil_image: PIL Image to inject metadata into
|
|
34
|
+
metadata: Dictionary of key-value pairs to inject
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Image bytes with metadata injected
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
Exception: On PNG save errors
|
|
41
|
+
"""
|
|
42
|
+
# Create PNG info object
|
|
43
|
+
png_info = PngImagePlugin.PngInfo()
|
|
44
|
+
|
|
45
|
+
# Preserve existing text chunks
|
|
46
|
+
for key, value in pil_image.info.items():
|
|
47
|
+
# Only preserve string key-value pairs (text chunks), skip binary data
|
|
48
|
+
# Don't preserve keys that will be overwritten by new metadata
|
|
49
|
+
if isinstance(key, str) and isinstance(value, str) and key not in metadata:
|
|
50
|
+
png_info.add_text(key, value)
|
|
51
|
+
|
|
52
|
+
# Add new metadata
|
|
53
|
+
for key, value in metadata.items():
|
|
54
|
+
png_info.add_text(key, str(value))
|
|
55
|
+
|
|
56
|
+
# Save with metadata
|
|
57
|
+
output_buffer = BytesIO()
|
|
58
|
+
pil_image.save(output_buffer, format="PNG", pnginfo=png_info)
|
|
59
|
+
return output_buffer.getvalue()
|
|
60
|
+
|
|
61
|
+
def extract_metadata(self, pil_image: Image.Image) -> dict[str, str]:
|
|
62
|
+
"""Extract all text chunks from PNG image.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
pil_image: PIL Image to extract metadata from
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dictionary of all PNG text chunks, empty dict if none found
|
|
69
|
+
"""
|
|
70
|
+
# PIL unpacks PNG text chunks directly into pil_image.info
|
|
71
|
+
return {key: value for key, value in pil_image.info.items() if isinstance(key, str) and isinstance(value, str)}
|
|
@@ -75,6 +75,38 @@ class BaseStorageDriver(ABC):
|
|
|
75
75
|
"""
|
|
76
76
|
...
|
|
77
77
|
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def get_asset_url(self, path: Path) -> str:
|
|
80
|
+
"""Get the permanent unsigned URL for an asset.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
path: The path of the file
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Permanent URL for accessing the asset
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def save_file(
|
|
92
|
+
self, path: Path, file_content: bytes, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Save a file to storage.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: The path of the file to save.
|
|
98
|
+
file_content: The file content as bytes.
|
|
99
|
+
existing_file_policy: How to handle existing files. Defaults to OVERWRITE.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The absolute file path where the file was saved.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
FileExistsError: When existing_file_policy is FAIL and file already exists.
|
|
106
|
+
RuntimeError: If file save fails.
|
|
107
|
+
"""
|
|
108
|
+
...
|
|
109
|
+
|
|
78
110
|
def upload_file(
|
|
79
111
|
self, path: Path, file_content: bytes, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
80
112
|
) -> str:
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from pathlib import Path
|
|
4
|
-
from
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urljoin, urlparse
|
|
5
7
|
|
|
6
8
|
import httpx
|
|
7
9
|
|
|
8
10
|
from griptape_nodes.drivers.storage.base_storage_driver import BaseStorageDriver, CreateSignedUploadUrlResponse
|
|
9
11
|
from griptape_nodes.retained_mode.events.os_events import ExistingFilePolicy
|
|
12
|
+
from griptape_nodes.utils.path_utils import get_workspace_relative_path
|
|
10
13
|
|
|
11
14
|
logger = logging.getLogger("griptape_nodes")
|
|
12
15
|
|
|
@@ -42,22 +45,24 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
42
45
|
def create_signed_upload_url(
|
|
43
46
|
self, path: Path, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
44
47
|
) -> CreateSignedUploadUrlResponse:
|
|
48
|
+
normalized_path = get_workspace_relative_path(path, self.workspace_directory)
|
|
49
|
+
|
|
45
50
|
if existing_file_policy != ExistingFilePolicy.OVERWRITE:
|
|
46
51
|
logger.warning(
|
|
47
52
|
"Griptape Cloud storage only supports OVERWRITE policy. "
|
|
48
53
|
"Requested policy '%s' will be ignored for file: %s",
|
|
49
54
|
existing_file_policy.value,
|
|
50
|
-
|
|
55
|
+
normalized_path,
|
|
51
56
|
)
|
|
52
57
|
|
|
53
|
-
self._create_asset(
|
|
58
|
+
self._create_asset(normalized_path.as_posix())
|
|
54
59
|
|
|
55
|
-
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{
|
|
60
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{normalized_path.as_posix()}")
|
|
56
61
|
try:
|
|
57
62
|
response = httpx.post(url, json={"operation": "PUT"}, headers=self.headers)
|
|
58
63
|
response.raise_for_status()
|
|
59
64
|
except httpx.HTTPStatusError as e:
|
|
60
|
-
msg = f"Failed to create presigned upload URL for file {
|
|
65
|
+
msg = f"Failed to create presigned upload URL for file {normalized_path}: {e}"
|
|
61
66
|
logger.error(msg)
|
|
62
67
|
raise RuntimeError(msg) from e
|
|
63
68
|
|
|
@@ -67,16 +72,77 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
67
72
|
"url": response_data["url"],
|
|
68
73
|
"headers": response_data.get("headers", {}),
|
|
69
74
|
"method": "PUT",
|
|
70
|
-
"file_path": str(
|
|
75
|
+
"file_path": str(normalized_path),
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
def _parse_cloud_asset_path(self, path: str | Path) -> Path:
|
|
79
|
+
"""Parse cloud asset URL path to extract workspace-relative portion.
|
|
80
|
+
|
|
81
|
+
Handles multiple input formats:
|
|
82
|
+
- Full URLs: https://cloud.griptape.ai/buckets/{id}/assets/{path}
|
|
83
|
+
- Path-only: /buckets/{id}/assets/{path}
|
|
84
|
+
- Workspace-relative: {path}
|
|
85
|
+
|
|
86
|
+
When a full URL is provided, validates that the domain matches
|
|
87
|
+
self.base_url to prevent cross-environment URL mixing.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
path: String or Path object that may contain a cloud asset URL pattern
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Workspace-relative path
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: When full URL domain doesn't match configured base_url
|
|
97
|
+
"""
|
|
98
|
+
# Convert to string for processing
|
|
99
|
+
path_str = str(path) if isinstance(path, Path) else path
|
|
100
|
+
|
|
101
|
+
# Validate domain for full URLs (only if it contains :// scheme separator)
|
|
102
|
+
if "://" in path_str and path_str.startswith(("http://", "https://")):
|
|
103
|
+
input_parsed = urlparse(path_str)
|
|
104
|
+
input_domain = input_parsed.netloc.lower()
|
|
105
|
+
|
|
106
|
+
base_parsed = urlparse(self.base_url)
|
|
107
|
+
expected_domain = base_parsed.netloc.lower()
|
|
108
|
+
|
|
109
|
+
if input_domain != expected_domain:
|
|
110
|
+
msg = (
|
|
111
|
+
f"Invalid cloud asset URL: domain '{input_domain}' does not match "
|
|
112
|
+
f"configured base URL '{self.base_url}'. "
|
|
113
|
+
f"Expected domain: '{expected_domain}'"
|
|
114
|
+
)
|
|
115
|
+
logger.error(msg)
|
|
116
|
+
raise ValueError(msg)
|
|
117
|
+
|
|
118
|
+
# Extract path component for further processing
|
|
119
|
+
path_str = input_parsed.path.lstrip("/")
|
|
120
|
+
|
|
121
|
+
# Check if it's a cloud asset URL pattern
|
|
122
|
+
# Handle both /buckets/ and buckets/ (after leading slash removal)
|
|
123
|
+
has_buckets = "/buckets/" in path_str or path_str.startswith("buckets/")
|
|
124
|
+
has_assets = "/assets/" in path_str
|
|
125
|
+
if has_buckets and has_assets:
|
|
126
|
+
# Extract workspace-relative path from cloud URL
|
|
127
|
+
# Format: /buckets/{bucket_id}/assets/{workspace_relative_path} or buckets/{bucket_id}/assets/{workspace_relative_path}
|
|
128
|
+
parts = path_str.split("/assets/", 1)
|
|
129
|
+
expected_parts_count = 2
|
|
130
|
+
if len(parts) == expected_parts_count:
|
|
131
|
+
return Path(parts[1]) # Return the workspace-relative path after /assets/
|
|
132
|
+
|
|
133
|
+
# For non-cloud paths, return as-is
|
|
134
|
+
return Path(path_str)
|
|
135
|
+
|
|
73
136
|
def create_signed_download_url(self, path: Path) -> str:
|
|
74
|
-
|
|
137
|
+
# Parse cloud asset URLs before normalizing
|
|
138
|
+
parsed_path = self._parse_cloud_asset_path(path)
|
|
139
|
+
normalized_path = get_workspace_relative_path(parsed_path, self.workspace_directory)
|
|
140
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{normalized_path.as_posix()}")
|
|
75
141
|
try:
|
|
76
142
|
response = httpx.post(url, json={"method": "GET"}, headers=self.headers)
|
|
77
143
|
response.raise_for_status()
|
|
78
144
|
except httpx.HTTPStatusError as e:
|
|
79
|
-
msg = f"Failed to create presigned download URL for file {
|
|
145
|
+
msg = f"Failed to create presigned download URL for file {normalized_path}: {e}"
|
|
80
146
|
logger.error(msg)
|
|
81
147
|
raise RuntimeError(msg) from e
|
|
82
148
|
|
|
@@ -84,6 +150,51 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
84
150
|
|
|
85
151
|
return response_data["url"]
|
|
86
152
|
|
|
153
|
+
def save_file(
|
|
154
|
+
self, path: Path, file_content: bytes, existing_file_policy: ExistingFilePolicy = ExistingFilePolicy.OVERWRITE
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Save a file to cloud storage via HTTP upload.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: The path of the file to save.
|
|
160
|
+
file_content: The file content as bytes.
|
|
161
|
+
existing_file_policy: How to handle existing files. Defaults to OVERWRITE.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The full asset URL for the saved file.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
RuntimeError: If file upload fails.
|
|
168
|
+
"""
|
|
169
|
+
normalized_path = get_workspace_relative_path(path, self.workspace_directory)
|
|
170
|
+
|
|
171
|
+
if existing_file_policy != ExistingFilePolicy.OVERWRITE:
|
|
172
|
+
logger.warning(
|
|
173
|
+
"GriptapeCloudStorageDriver only supports OVERWRITE policy, got %s. "
|
|
174
|
+
"The file will be overwritten if it exists.",
|
|
175
|
+
existing_file_policy,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Get signed upload URL
|
|
179
|
+
upload_response = self.create_signed_upload_url(path, existing_file_policy)
|
|
180
|
+
|
|
181
|
+
# Upload the file using the signed URL
|
|
182
|
+
try:
|
|
183
|
+
response = httpx.request(
|
|
184
|
+
upload_response["method"],
|
|
185
|
+
upload_response["url"],
|
|
186
|
+
content=file_content,
|
|
187
|
+
headers=upload_response["headers"],
|
|
188
|
+
)
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
except httpx.HTTPStatusError as e:
|
|
191
|
+
msg = f"Failed to upload file {normalized_path}: {e}"
|
|
192
|
+
logger.error(msg)
|
|
193
|
+
raise RuntimeError(msg) from e
|
|
194
|
+
|
|
195
|
+
# Return the full asset URL
|
|
196
|
+
return urljoin(self.base_url, f"/buckets/{self.bucket_id}/assets/{normalized_path.as_posix()}")
|
|
197
|
+
|
|
87
198
|
def _create_asset(self, asset_name: str) -> str:
|
|
88
199
|
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/assets")
|
|
89
200
|
try:
|
|
@@ -160,6 +271,22 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
160
271
|
|
|
161
272
|
return file_names
|
|
162
273
|
|
|
274
|
+
def get_asset_url(self, path: Path) -> str:
|
|
275
|
+
"""Get the permanent unsigned URL for a cloud asset.
|
|
276
|
+
|
|
277
|
+
Returns the permanent public URL for the asset (not the presigned URL).
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
path: The path of the file
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Permanent cloud asset URL
|
|
284
|
+
"""
|
|
285
|
+
# Parse cloud asset URLs before normalizing
|
|
286
|
+
parsed_path = self._parse_cloud_asset_path(path)
|
|
287
|
+
normalized_path = get_workspace_relative_path(parsed_path, self.workspace_directory)
|
|
288
|
+
return urljoin(self.base_url, f"/buckets/{self.bucket_id}/assets/{normalized_path.as_posix()}")
|
|
289
|
+
|
|
163
290
|
@staticmethod
|
|
164
291
|
def list_buckets(*, base_url: str, api_key: str) -> list[dict]:
|
|
165
292
|
"""List all buckets in Griptape Cloud.
|
|
@@ -190,12 +317,259 @@ class GriptapeCloudStorageDriver(BaseStorageDriver):
|
|
|
190
317
|
Args:
|
|
191
318
|
path: The path of the file to delete.
|
|
192
319
|
"""
|
|
193
|
-
|
|
320
|
+
normalized_path = get_workspace_relative_path(path, self.workspace_directory)
|
|
321
|
+
url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/assets/{normalized_path.as_posix()}")
|
|
194
322
|
|
|
195
323
|
try:
|
|
196
324
|
response = httpx.delete(url, headers=self.headers)
|
|
197
325
|
response.raise_for_status()
|
|
198
326
|
except httpx.HTTPStatusError as e:
|
|
199
|
-
msg = f"Failed to delete file {
|
|
327
|
+
msg = f"Failed to delete file {normalized_path}: {e}"
|
|
200
328
|
logger.error(msg)
|
|
201
329
|
raise RuntimeError(msg) from e
|
|
330
|
+
|
|
331
|
+
def _is_cloud_asset_url(self, url_str: str) -> bool:
|
|
332
|
+
"""Check if URL is a Griptape Cloud asset URL with domain validation.
|
|
333
|
+
|
|
334
|
+
Detects URLs matching pattern: https://cloud.griptape.ai/buckets/{id}/assets/{path}
|
|
335
|
+
Validates domain matches expected cloud domain.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
url_str: String to check
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
True if url_str is a valid cloud asset URL
|
|
342
|
+
"""
|
|
343
|
+
# Fast negative checks first
|
|
344
|
+
if not url_str:
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
# Must be a full URL with scheme
|
|
348
|
+
if not url_str.startswith(("http://", "https://")):
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
# Parse URL to check domain
|
|
352
|
+
parsed = urlparse(url_str)
|
|
353
|
+
domain = parsed.netloc.lower()
|
|
354
|
+
|
|
355
|
+
# Get expected cloud domain from instance
|
|
356
|
+
expected_parsed = urlparse(self.base_url)
|
|
357
|
+
expected_domain = expected_parsed.netloc.lower()
|
|
358
|
+
|
|
359
|
+
# Domain must match
|
|
360
|
+
if domain != expected_domain:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
# Must contain both /buckets/ and /assets/ patterns
|
|
364
|
+
path = parsed.path
|
|
365
|
+
has_buckets = "/buckets/" in path
|
|
366
|
+
has_assets = "/assets/" in path
|
|
367
|
+
|
|
368
|
+
# Success path - valid cloud asset URL
|
|
369
|
+
return has_buckets and has_assets
|
|
370
|
+
|
|
371
|
+
def _extract_workspace_path_from_cloud_url(self, url_str: str) -> str | None:
|
|
372
|
+
"""Extract workspace-relative path from cloud asset URL.
|
|
373
|
+
|
|
374
|
+
Parses URLs like: /buckets/{bucket_id}/assets/{workspace_path}
|
|
375
|
+
Returns just the {workspace_path} portion.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
url_str: Cloud asset URL
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Workspace-relative path, or None if parsing fails
|
|
382
|
+
"""
|
|
383
|
+
parsed = urlparse(url_str)
|
|
384
|
+
path = parsed.path
|
|
385
|
+
|
|
386
|
+
# Extract workspace-relative path from: /buckets/{bucket_id}/assets/{workspace_path}
|
|
387
|
+
expected_parts = 2
|
|
388
|
+
try:
|
|
389
|
+
parts = path.split("/assets/", 1)
|
|
390
|
+
if len(parts) != expected_parts:
|
|
391
|
+
return None
|
|
392
|
+
return parts[1]
|
|
393
|
+
except Exception:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
def _create_signed_download_url_from_asset_url(self, asset_url: str) -> str | None:
|
|
397
|
+
"""Create a signed download URL for a cloud asset.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
asset_url: Cloud asset URL to convert
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Signed download URL if successful, None if fails
|
|
404
|
+
"""
|
|
405
|
+
# Extract workspace-relative path
|
|
406
|
+
workspace_path = self._extract_workspace_path_from_cloud_url(asset_url)
|
|
407
|
+
if not workspace_path:
|
|
408
|
+
logger.debug("Could not extract workspace path from cloud URL: %s", asset_url)
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
# Build API URL for signed download URL
|
|
412
|
+
api_url = urljoin(self.base_url, f"/api/buckets/{self.bucket_id}/asset-urls/{workspace_path}")
|
|
413
|
+
|
|
414
|
+
# Make API request to get signed URL
|
|
415
|
+
try:
|
|
416
|
+
response = httpx.post(api_url, json={"method": "GET"}, headers=self.headers)
|
|
417
|
+
response.raise_for_status()
|
|
418
|
+
|
|
419
|
+
response_data = response.json()
|
|
420
|
+
signed_url = response_data["url"]
|
|
421
|
+
|
|
422
|
+
logger.info("Converted cloud asset URL to signed URL: %s", asset_url)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
425
|
+
logger.warning(
|
|
426
|
+
"Failed to create signed download URL for %s: HTTP %s", asset_url, e.response.status_code
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
logger.warning("Failed to create signed download URL for %s: %s", asset_url, e)
|
|
430
|
+
return None
|
|
431
|
+
else:
|
|
432
|
+
return signed_url
|
|
433
|
+
|
|
434
|
+
@staticmethod
|
|
435
|
+
def is_cloud_asset_url(url_str: str, base_url: str | None = None) -> bool:
|
|
436
|
+
"""Check if URL is a Griptape Cloud asset URL with domain validation.
|
|
437
|
+
|
|
438
|
+
Static version for use without driver instance (e.g., httpx patching layer).
|
|
439
|
+
Detects URLs matching pattern: https://cloud.griptape.ai/buckets/{id}/assets/{path}
|
|
440
|
+
Validates domain matches expected cloud domain.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
url_str: String to check
|
|
444
|
+
base_url: Expected cloud domain URL. If None, reads from GT_CLOUD_BASE_URL env var.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
True if url_str is a valid cloud asset URL
|
|
448
|
+
"""
|
|
449
|
+
# Fast negative checks first
|
|
450
|
+
if not url_str:
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
# Must be a full URL with scheme
|
|
454
|
+
if not url_str.startswith(("http://", "https://")):
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
# Parse URL to check domain
|
|
458
|
+
parsed = urlparse(url_str)
|
|
459
|
+
domain = parsed.netloc.lower()
|
|
460
|
+
|
|
461
|
+
# Get expected cloud domain from parameter or environment
|
|
462
|
+
if base_url is None:
|
|
463
|
+
base_url = os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
|
|
464
|
+
|
|
465
|
+
expected_parsed = urlparse(base_url)
|
|
466
|
+
expected_domain = expected_parsed.netloc.lower()
|
|
467
|
+
|
|
468
|
+
# Domain must match
|
|
469
|
+
if domain != expected_domain:
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
# Must contain both /buckets/ and /assets/ patterns
|
|
473
|
+
path = parsed.path
|
|
474
|
+
has_buckets = "/buckets/" in path
|
|
475
|
+
has_assets = "/assets/" in path
|
|
476
|
+
|
|
477
|
+
# Success path - valid cloud asset URL
|
|
478
|
+
return has_buckets and has_assets
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def extract_workspace_path_from_cloud_url(url_str: str) -> str | None:
|
|
482
|
+
"""Extract workspace-relative path from cloud asset URL.
|
|
483
|
+
|
|
484
|
+
Static version for use without driver instance.
|
|
485
|
+
Parses URLs like: /buckets/{bucket_id}/assets/{workspace_path}
|
|
486
|
+
Returns just the {workspace_path} portion.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
url_str: Cloud asset URL
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Workspace-relative path, or None if parsing fails
|
|
493
|
+
"""
|
|
494
|
+
parsed = urlparse(url_str)
|
|
495
|
+
path = parsed.path
|
|
496
|
+
|
|
497
|
+
# Extract workspace-relative path from: /buckets/{bucket_id}/assets/{workspace_path}
|
|
498
|
+
expected_parts = 2
|
|
499
|
+
try:
|
|
500
|
+
parts = path.split("/assets/", 1)
|
|
501
|
+
if len(parts) != expected_parts:
|
|
502
|
+
return None
|
|
503
|
+
return parts[1]
|
|
504
|
+
except Exception:
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def create_signed_download_url_from_asset_url(
|
|
509
|
+
asset_url: str,
|
|
510
|
+
bucket_id: str | None = None,
|
|
511
|
+
api_key: str | None = None,
|
|
512
|
+
base_url: str | None = None,
|
|
513
|
+
*,
|
|
514
|
+
httpx_request_func: Callable[..., Any],
|
|
515
|
+
) -> str | None:
|
|
516
|
+
"""Create a signed download URL for a cloud asset.
|
|
517
|
+
|
|
518
|
+
Static version for use without driver instance (e.g., httpx patching layer).
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
asset_url: Cloud asset URL to convert
|
|
522
|
+
bucket_id: Bucket ID. If None, reads from GT_CLOUD_BUCKET_ID env var.
|
|
523
|
+
api_key: API key. If None, reads from GT_CLOUD_API_KEY env var.
|
|
524
|
+
base_url: Cloud base URL. If None, reads from GT_CLOUD_BASE_URL env var.
|
|
525
|
+
httpx_request_func: The httpx request function to use (original, not patched)
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Signed download URL if successful, None if fails
|
|
529
|
+
"""
|
|
530
|
+
# Get credentials from parameters or environment
|
|
531
|
+
if bucket_id is None:
|
|
532
|
+
bucket_id = os.environ.get("GT_CLOUD_BUCKET_ID")
|
|
533
|
+
if api_key is None:
|
|
534
|
+
api_key = os.environ.get("GT_CLOUD_API_KEY")
|
|
535
|
+
if base_url is None:
|
|
536
|
+
base_url = os.environ.get("GT_CLOUD_BASE_URL", "https://cloud.griptape.ai")
|
|
537
|
+
|
|
538
|
+
# Guard: Check for required credentials
|
|
539
|
+
if not bucket_id:
|
|
540
|
+
logger.debug("GT_CLOUD_BUCKET_ID not set, skipping cloud URL conversion: %s", asset_url)
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
if not api_key:
|
|
544
|
+
logger.debug("GT_CLOUD_API_KEY not set, skipping cloud URL conversion: %s", asset_url)
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
# Extract workspace-relative path
|
|
548
|
+
workspace_path = GriptapeCloudStorageDriver.extract_workspace_path_from_cloud_url(asset_url)
|
|
549
|
+
if not workspace_path:
|
|
550
|
+
logger.debug("Could not extract workspace path from cloud URL: %s", asset_url)
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
# Build API URL for signed download URL
|
|
554
|
+
api_url = urljoin(base_url, f"/api/buckets/{bucket_id}/asset-urls/{workspace_path}")
|
|
555
|
+
|
|
556
|
+
# Make API request to get signed URL
|
|
557
|
+
try:
|
|
558
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
559
|
+
response = httpx_request_func("POST", api_url, json={"method": "GET"}, headers=headers)
|
|
560
|
+
response.raise_for_status()
|
|
561
|
+
|
|
562
|
+
response_data = response.json()
|
|
563
|
+
signed_url = response_data["url"]
|
|
564
|
+
|
|
565
|
+
logger.info("Converted cloud asset URL to signed URL: %s", asset_url)
|
|
566
|
+
except Exception as e:
|
|
567
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
568
|
+
logger.warning(
|
|
569
|
+
"Failed to create signed download URL for %s: HTTP %s", asset_url, e.response.status_code
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
logger.warning("Failed to create signed download URL for %s: %s", asset_url, e)
|
|
573
|
+
return None
|
|
574
|
+
else:
|
|
575
|
+
return signed_url
|