griptape-nodes 0.41.0__py3-none-any.whl → 0.43.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. griptape_nodes/__init__.py +0 -0
  2. griptape_nodes/app/.python-version +0 -0
  3. griptape_nodes/app/__init__.py +1 -10
  4. griptape_nodes/app/api.py +199 -0
  5. griptape_nodes/app/app.py +140 -222
  6. griptape_nodes/app/watch.py +4 -2
  7. griptape_nodes/bootstrap/__init__.py +0 -0
  8. griptape_nodes/bootstrap/bootstrap_script.py +0 -0
  9. griptape_nodes/bootstrap/register_libraries_script.py +0 -0
  10. griptape_nodes/bootstrap/structure_config.yaml +0 -0
  11. griptape_nodes/bootstrap/workflow_executors/__init__.py +0 -0
  12. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +0 -0
  13. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +0 -0
  14. griptape_nodes/bootstrap/workflow_runners/__init__.py +0 -0
  15. griptape_nodes/bootstrap/workflow_runners/bootstrap_workflow_runner.py +0 -0
  16. griptape_nodes/bootstrap/workflow_runners/local_workflow_runner.py +0 -0
  17. griptape_nodes/bootstrap/workflow_runners/subprocess_workflow_runner.py +6 -2
  18. griptape_nodes/bootstrap/workflow_runners/workflow_runner.py +0 -0
  19. griptape_nodes/drivers/__init__.py +0 -0
  20. griptape_nodes/drivers/storage/__init__.py +0 -0
  21. griptape_nodes/drivers/storage/base_storage_driver.py +0 -0
  22. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +0 -0
  23. griptape_nodes/drivers/storage/local_storage_driver.py +5 -3
  24. griptape_nodes/drivers/storage/storage_backend.py +0 -0
  25. griptape_nodes/exe_types/__init__.py +0 -0
  26. griptape_nodes/exe_types/connections.py +0 -0
  27. griptape_nodes/exe_types/core_types.py +0 -0
  28. griptape_nodes/exe_types/flow.py +68 -368
  29. griptape_nodes/exe_types/node_types.py +17 -1
  30. griptape_nodes/exe_types/type_validator.py +0 -0
  31. griptape_nodes/machines/__init__.py +0 -0
  32. griptape_nodes/machines/control_flow.py +52 -20
  33. griptape_nodes/machines/fsm.py +16 -2
  34. griptape_nodes/machines/node_resolution.py +16 -14
  35. griptape_nodes/mcp_server/__init__.py +1 -0
  36. griptape_nodes/mcp_server/server.py +126 -0
  37. griptape_nodes/mcp_server/ws_request_manager.py +268 -0
  38. griptape_nodes/node_library/__init__.py +0 -0
  39. griptape_nodes/node_library/advanced_node_library.py +0 -0
  40. griptape_nodes/node_library/library_registry.py +0 -0
  41. griptape_nodes/node_library/workflow_registry.py +2 -2
  42. griptape_nodes/py.typed +0 -0
  43. griptape_nodes/retained_mode/__init__.py +0 -0
  44. griptape_nodes/retained_mode/events/__init__.py +0 -0
  45. griptape_nodes/retained_mode/events/agent_events.py +70 -8
  46. griptape_nodes/retained_mode/events/app_events.py +137 -12
  47. griptape_nodes/retained_mode/events/arbitrary_python_events.py +23 -0
  48. griptape_nodes/retained_mode/events/base_events.py +13 -31
  49. griptape_nodes/retained_mode/events/config_events.py +87 -11
  50. griptape_nodes/retained_mode/events/connection_events.py +56 -5
  51. griptape_nodes/retained_mode/events/context_events.py +27 -4
  52. griptape_nodes/retained_mode/events/execution_events.py +99 -14
  53. griptape_nodes/retained_mode/events/flow_events.py +165 -7
  54. griptape_nodes/retained_mode/events/generate_request_payload_schemas.py +0 -0
  55. griptape_nodes/retained_mode/events/library_events.py +195 -17
  56. griptape_nodes/retained_mode/events/logger_events.py +11 -0
  57. griptape_nodes/retained_mode/events/node_events.py +242 -22
  58. griptape_nodes/retained_mode/events/object_events.py +40 -4
  59. griptape_nodes/retained_mode/events/os_events.py +116 -3
  60. griptape_nodes/retained_mode/events/parameter_events.py +212 -8
  61. griptape_nodes/retained_mode/events/payload_registry.py +0 -0
  62. griptape_nodes/retained_mode/events/secrets_events.py +59 -7
  63. griptape_nodes/retained_mode/events/static_file_events.py +57 -4
  64. griptape_nodes/retained_mode/events/validation_events.py +39 -4
  65. griptape_nodes/retained_mode/events/workflow_events.py +188 -17
  66. griptape_nodes/retained_mode/griptape_nodes.py +89 -363
  67. griptape_nodes/retained_mode/managers/__init__.py +0 -0
  68. griptape_nodes/retained_mode/managers/agent_manager.py +49 -23
  69. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +0 -0
  70. griptape_nodes/retained_mode/managers/config_manager.py +0 -0
  71. griptape_nodes/retained_mode/managers/context_manager.py +0 -0
  72. griptape_nodes/retained_mode/managers/engine_identity_manager.py +146 -0
  73. griptape_nodes/retained_mode/managers/event_manager.py +14 -2
  74. griptape_nodes/retained_mode/managers/flow_manager.py +751 -64
  75. griptape_nodes/retained_mode/managers/library_lifecycle/__init__.py +45 -0
  76. griptape_nodes/retained_mode/managers/library_lifecycle/data_models.py +191 -0
  77. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +346 -0
  78. griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +439 -0
  79. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/__init__.py +17 -0
  80. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +82 -0
  81. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +116 -0
  82. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +352 -0
  83. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +104 -0
  84. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +155 -0
  85. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance.py +18 -0
  86. griptape_nodes/retained_mode/managers/library_lifecycle/library_status.py +12 -0
  87. griptape_nodes/retained_mode/managers/library_manager.py +255 -40
  88. griptape_nodes/retained_mode/managers/node_manager.py +120 -103
  89. griptape_nodes/retained_mode/managers/object_manager.py +11 -3
  90. griptape_nodes/retained_mode/managers/operation_manager.py +0 -0
  91. griptape_nodes/retained_mode/managers/os_manager.py +582 -8
  92. griptape_nodes/retained_mode/managers/secrets_manager.py +4 -0
  93. griptape_nodes/retained_mode/managers/session_manager.py +328 -0
  94. griptape_nodes/retained_mode/managers/settings.py +7 -0
  95. griptape_nodes/retained_mode/managers/static_files_manager.py +0 -0
  96. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +2 -2
  97. griptape_nodes/retained_mode/managers/workflow_manager.py +722 -456
  98. griptape_nodes/retained_mode/retained_mode.py +44 -0
  99. griptape_nodes/retained_mode/utils/__init__.py +0 -0
  100. griptape_nodes/retained_mode/utils/engine_identity.py +141 -27
  101. griptape_nodes/retained_mode/utils/name_generator.py +0 -0
  102. griptape_nodes/traits/__init__.py +0 -0
  103. griptape_nodes/traits/add_param_button.py +0 -0
  104. griptape_nodes/traits/button.py +0 -0
  105. griptape_nodes/traits/clamp.py +0 -0
  106. griptape_nodes/traits/compare.py +0 -0
  107. griptape_nodes/traits/compare_images.py +0 -0
  108. griptape_nodes/traits/file_system_picker.py +127 -0
  109. griptape_nodes/traits/minmax.py +0 -0
  110. griptape_nodes/traits/options.py +0 -0
  111. griptape_nodes/traits/slider.py +0 -0
  112. griptape_nodes/traits/trait_registry.py +0 -0
  113. griptape_nodes/traits/traits.json +0 -0
  114. griptape_nodes/updater/__init__.py +2 -2
  115. griptape_nodes/updater/__main__.py +0 -0
  116. griptape_nodes/utils/__init__.py +0 -0
  117. griptape_nodes/utils/dict_utils.py +0 -0
  118. griptape_nodes/utils/image_preview.py +128 -0
  119. griptape_nodes/utils/metaclasses.py +0 -0
  120. griptape_nodes/version_compatibility/__init__.py +0 -0
  121. griptape_nodes/version_compatibility/versions/__init__.py +0 -0
  122. griptape_nodes/version_compatibility/versions/v0_39_0/__init__.py +0 -0
  123. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +5 -5
  124. griptape_nodes-0.43.0.dist-info/METADATA +90 -0
  125. griptape_nodes-0.43.0.dist-info/RECORD +129 -0
  126. griptape_nodes-0.43.0.dist-info/WHEEL +4 -0
  127. {griptape_nodes-0.41.0.dist-info → griptape_nodes-0.43.0.dist-info}/entry_points.txt +1 -0
  128. griptape_nodes/app/app_sessions.py +0 -458
  129. griptape_nodes/retained_mode/utils/session_persistence.py +0 -105
  130. griptape_nodes-0.41.0.dist-info/METADATA +0 -78
  131. griptape_nodes-0.41.0.dist-info/RECORD +0 -112
  132. griptape_nodes-0.41.0.dist-info/WHEEL +0 -4
  133. griptape_nodes-0.41.0.dist-info/licenses/LICENSE +0 -201
@@ -1,22 +1,42 @@
1
- import logging
1
+ import base64
2
+ import mimetypes
2
3
  import os
4
+ import shutil
3
5
  import subprocess
4
6
  import sys
7
+ from dataclasses import dataclass
5
8
  from pathlib import Path
6
9
  from typing import Any
7
10
 
11
+ from binaryornot.check import is_binary
8
12
  from rich.console import Console
9
13
 
10
14
  from griptape_nodes.retained_mode.events.base_events import ResultPayload
11
15
  from griptape_nodes.retained_mode.events.os_events import (
16
+ FileSystemEntry,
17
+ ListDirectoryRequest,
18
+ ListDirectoryResultFailure,
19
+ ListDirectoryResultSuccess,
12
20
  OpenAssociatedFileRequest,
13
21
  OpenAssociatedFileResultFailure,
14
22
  OpenAssociatedFileResultSuccess,
23
+ ReadFileRequest,
24
+ ReadFileResultFailure,
25
+ ReadFileResultSuccess,
15
26
  )
27
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes, logger
16
28
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
17
29
 
18
30
  console = Console()
19
- logger = logging.getLogger("griptape_nodes")
31
+
32
+
33
+ @dataclass
34
+ class DiskSpaceInfo:
35
+ """Information about disk space usage."""
36
+
37
+ total: int
38
+ used: int
39
+ free: int
20
40
 
21
41
 
22
42
  class OSManager:
@@ -31,6 +51,127 @@ class OSManager:
31
51
  event_manager.assign_manager_to_request_type(
32
52
  request_type=OpenAssociatedFileRequest, callback=self.on_open_associated_file_request
33
53
  )
54
+ event_manager.assign_manager_to_request_type(
55
+ request_type=ListDirectoryRequest, callback=self.on_list_directory_request
56
+ )
57
+
58
+ event_manager.assign_manager_to_request_type(
59
+ request_type=ReadFileRequest, callback=self.on_read_file_request
60
+ )
61
+
62
+ def _get_workspace_path(self) -> Path:
63
+ """Get the workspace path from config."""
64
+ return GriptapeNodes.ConfigManager().workspace_path
65
+
66
+ def _expand_path(self, path_str: str) -> Path:
67
+ """Expand a path string, handling tilde and environment variables.
68
+
69
+ Args:
70
+ path_str: Path string that may contain ~ or environment variables
71
+
72
+ Returns:
73
+ Expanded Path object
74
+ """
75
+ # Expand environment variables first, then tilde
76
+ expanded_vars = os.path.expandvars(path_str)
77
+ return Path(expanded_vars).expanduser().resolve()
78
+
79
+ def _resolve_file_path(self, path_str: str, *, workspace_only: bool = False) -> Path:
80
+ """Resolve a file path, handling absolute, relative, and tilde paths.
81
+
82
+ Args:
83
+ path_str: Path string that may be absolute, relative, or start with ~
84
+ workspace_only: If True and path is invalid, fall back to workspace directory
85
+
86
+ Returns:
87
+ Resolved Path object
88
+ """
89
+ try:
90
+ if Path(path_str).is_absolute() or path_str.startswith("~"):
91
+ # Expand tilde and environment variables for absolute paths or paths starting with ~
92
+ return self._expand_path(path_str)
93
+ # Both workspace and system-wide modes resolve relative to current directory
94
+ return (self._get_workspace_path() / path_str).resolve()
95
+ except (ValueError, RuntimeError):
96
+ if workspace_only:
97
+ msg = f"Path '{path_str}' not found, using workspace directory: {self._get_workspace_path()}"
98
+ logger.warning(msg)
99
+ return self._get_workspace_path()
100
+ # Re-raise the exception for non-workspace mode
101
+ raise
102
+
103
+ def _validate_workspace_path(self, path: Path) -> tuple[bool, Path]:
104
+ """Check if a path is within workspace and return relative path if it is.
105
+
106
+ Args:
107
+ path: Path to validate
108
+
109
+ Returns:
110
+ Tuple of (is_workspace_path, relative_or_absolute_path)
111
+ """
112
+ workspace = GriptapeNodes.ConfigManager().workspace_path
113
+
114
+ # Ensure both paths are resolved for comparison
115
+ path = path.resolve()
116
+ workspace = workspace.resolve()
117
+
118
+ msg = f"Validating path: {path} against workspace: {workspace}"
119
+ logger.debug(msg)
120
+
121
+ try:
122
+ relative = path.relative_to(workspace)
123
+ except ValueError:
124
+ msg = f"Path is outside workspace: {path}"
125
+ logger.debug(msg)
126
+ return False, path
127
+
128
+ msg = f"Path is within workspace, relative path: {relative}"
129
+ logger.debug(msg)
130
+ return True, relative
131
+
132
+ def _validate_read_file_request(self, request: ReadFileRequest) -> tuple[Path, str]:
133
+ """Validate read file request and return resolved file path and path string."""
134
+ # Validate that exactly one of file_path or file_entry is provided
135
+ if request.file_path is None and request.file_entry is None:
136
+ msg = "Either file_path or file_entry must be provided"
137
+ logger.error(msg)
138
+ raise ValueError(msg)
139
+
140
+ if request.file_path is not None and request.file_entry is not None:
141
+ msg = "Only one of file_path or file_entry should be provided, not both"
142
+ logger.error(msg)
143
+ raise ValueError(msg)
144
+
145
+ # Get the file path to read - handle paths consistently
146
+ if request.file_entry is not None:
147
+ file_path_str = request.file_entry.path
148
+ elif request.file_path is not None:
149
+ file_path_str = request.file_path
150
+ else:
151
+ msg = "No valid file path provided"
152
+ logger.error(msg)
153
+ raise ValueError(msg)
154
+
155
+ file_path = self._resolve_file_path(file_path_str, workspace_only=request.workspace_only is True)
156
+
157
+ # Check if file exists and is actually a file
158
+ if not file_path.exists():
159
+ msg = f"File does not exist: {file_path}"
160
+ logger.error(msg)
161
+ raise FileNotFoundError(msg)
162
+ if not file_path.is_file():
163
+ msg = f"File is not a file: {file_path}"
164
+ logger.error(msg)
165
+ raise FileNotFoundError(msg)
166
+
167
+ # Check workspace constraints
168
+ is_workspace_path, _ = self._validate_workspace_path(file_path)
169
+ if request.workspace_only and not is_workspace_path:
170
+ msg = f"File is outside workspace: {file_path}"
171
+ logger.error(msg)
172
+ raise ValueError(msg)
173
+
174
+ return file_path, file_path_str
34
175
 
35
176
  @staticmethod
36
177
  def platform() -> str:
@@ -63,12 +204,43 @@ class OSManager:
63
204
  sys.stdout.flush() # Recommended here https://docs.python.org/3/library/os.html#os.execvpe
64
205
  os.execvp(args[0], args) # noqa: S606
65
206
 
66
- def on_open_associated_file_request(self, request: OpenAssociatedFileRequest) -> ResultPayload: # noqa: PLR0911
207
+ def on_open_associated_file_request(self, request: OpenAssociatedFileRequest) -> ResultPayload: # noqa: C901, PLR0911, PLR0912
208
+ # Validate that exactly one of path_to_file or file_entry is provided
209
+ if request.path_to_file is None and request.file_entry is None:
210
+ msg = "Either path_to_file or file_entry must be provided"
211
+ logger.error(msg)
212
+ return OpenAssociatedFileResultFailure()
213
+
214
+ if request.path_to_file is not None and request.file_entry is not None:
215
+ msg = "Only one of path_to_file or file_entry should be provided, not both"
216
+ logger.error(msg)
217
+ return OpenAssociatedFileResultFailure()
218
+
219
+ # Get the file path to open
220
+ if request.file_entry is not None:
221
+ # Use the path from the FileSystemEntry
222
+ file_path_str = request.file_entry.path
223
+ elif request.path_to_file is not None:
224
+ # Use the provided path_to_file
225
+ file_path_str = request.path_to_file
226
+ else:
227
+ # This should never happen due to validation above, but type checker needs it
228
+ msg = "No valid file path provided"
229
+ logger.error(msg)
230
+ return OpenAssociatedFileResultFailure()
231
+
232
+ # At this point, file_path_str is guaranteed to be a string
233
+ if file_path_str is None:
234
+ msg = "No valid file path provided"
235
+ logger.error(msg)
236
+ return OpenAssociatedFileResultFailure()
237
+
67
238
  # Sanitize and validate the file path
68
239
  try:
69
- path = Path(request.path_to_file).resolve(strict=True)
240
+ # Resolve the path (no workspace fallback for open requests)
241
+ path = self._resolve_file_path(file_path_str, workspace_only=False)
70
242
  except (ValueError, RuntimeError):
71
- details = f"Invalid file path: '{request.path_to_file}'"
243
+ details = f"Invalid file path: '{file_path_str}'"
72
244
  logger.info(details)
73
245
  return OpenAssociatedFileResultFailure()
74
246
 
@@ -81,12 +253,12 @@ class OSManager:
81
253
 
82
254
  try:
83
255
  platform_name = sys.platform
84
- if self.is_windows:
256
+ if self.is_windows():
85
257
  # Linter complains but this is the recommended way on Windows
86
258
  # We can ignore this warning as we've validated the path
87
259
  os.startfile(str(path)) # noqa: S606 # pyright: ignore[reportAttributeAccessIssue]
88
260
  logger.info("Started file on Windows: %s", path)
89
- elif self.is_mac:
261
+ elif self.is_mac():
90
262
  # On macOS, open should be in a standard location
91
263
  subprocess.run( # noqa: S603
92
264
  ["/usr/bin/open", str(path)],
@@ -95,7 +267,7 @@ class OSManager:
95
267
  text=True,
96
268
  )
97
269
  logger.info("Started file on macOS: %s", path)
98
- elif self.is_linux:
270
+ elif self.is_linux():
99
271
  # Use full path to xdg-open to satisfy linter
100
272
  # Common locations for xdg-open:
101
273
  xdg_paths = ["/usr/bin/xdg-open", "/bin/xdg-open", "/usr/local/bin/xdg-open"]
@@ -129,3 +301,405 @@ class OSManager:
129
301
  except Exception as e:
130
302
  logger.error("Exception occurred when trying to open file: %s", type(e).__name__)
131
303
  return OpenAssociatedFileResultFailure()
304
+
305
+ def on_list_directory_request(self, request: ListDirectoryRequest) -> ResultPayload: # noqa: C901, PLR0911
306
+ """Handle a request to list directory contents."""
307
+ try:
308
+ # Get the directory path to list
309
+ if request.directory_path is None:
310
+ directory = self._get_workspace_path()
311
+ # Handle paths consistently - always resolve relative paths relative to current directory
312
+ elif Path(request.directory_path).is_absolute() or request.directory_path.startswith("~"):
313
+ # Expand tilde and environment variables for absolute paths or paths starting with ~
314
+ directory = self._expand_path(request.directory_path)
315
+ else:
316
+ # Both workspace and system-wide modes resolve relative to current directory
317
+ directory = (self._get_workspace_path() / request.directory_path).resolve()
318
+
319
+ # Check if directory exists
320
+ if not directory.exists():
321
+ msg = f"Directory does not exist: {directory}"
322
+ logger.error(msg)
323
+ return ListDirectoryResultFailure()
324
+ if not directory.is_dir():
325
+ msg = f"Directory is not a directory: {directory}"
326
+ logger.error(msg)
327
+ return ListDirectoryResultFailure()
328
+
329
+ # Check workspace constraints
330
+ is_workspace_path, relative_or_abs_path = self._validate_workspace_path(directory)
331
+ if request.workspace_only and not is_workspace_path:
332
+ msg = f"Directory is outside workspace: {directory}"
333
+ logger.error(msg)
334
+ return ListDirectoryResultFailure()
335
+
336
+ entries = []
337
+ try:
338
+ # List directory contents
339
+ for entry in directory.iterdir():
340
+ # Skip hidden files if not requested
341
+ if not request.show_hidden and entry.name.startswith("."):
342
+ continue
343
+
344
+ try:
345
+ stat = entry.stat()
346
+ # Get path relative to workspace if within workspace
347
+ is_entry_in_workspace, entry_path = self._validate_workspace_path(entry)
348
+ entries.append(
349
+ FileSystemEntry(
350
+ name=entry.name,
351
+ path=str(entry_path),
352
+ is_dir=entry.is_dir(),
353
+ size=stat.st_size,
354
+ modified_time=stat.st_mtime,
355
+ )
356
+ )
357
+ except (OSError, PermissionError) as e:
358
+ msg = f"Could not stat entry {entry}: {e}"
359
+ logger.warning(msg)
360
+ continue
361
+
362
+ except (OSError, PermissionError) as e:
363
+ msg = f"Error listing directory {directory}: {e}"
364
+ logger.error(msg)
365
+ return ListDirectoryResultFailure()
366
+
367
+ # Return appropriate path format based on mode
368
+ if request.workspace_only:
369
+ # In workspace mode, return relative path if within workspace, absolute if outside
370
+ return ListDirectoryResultSuccess(
371
+ entries=entries, current_path=str(relative_or_abs_path), is_workspace_path=is_workspace_path
372
+ )
373
+ # In system-wide mode, always return the full absolute path
374
+ return ListDirectoryResultSuccess(
375
+ entries=entries, current_path=str(directory), is_workspace_path=is_workspace_path
376
+ )
377
+
378
+ except Exception as e:
379
+ msg = f"Unexpected error in list_directory: {type(e).__name__}: {e}"
380
+ logger.error(msg)
381
+ return ListDirectoryResultFailure()
382
+
383
+ def on_read_file_request(self, request: ReadFileRequest) -> ResultPayload:
384
+ """Handle a request to read file contents with automatic text/binary detection."""
385
+ # Initialize variables that might be used in exception handlers
386
+ file_path: Path | None = None
387
+ file_path_str: str | None = None
388
+
389
+ try:
390
+ # Validate request and get file path
391
+ file_path, file_path_str = self._validate_read_file_request(request)
392
+
393
+ # Read file content
394
+ content, encoding, mime_type, compression_encoding, file_size = self._read_file_content(file_path, request)
395
+
396
+ return ReadFileResultSuccess(
397
+ content=content,
398
+ file_size=file_size,
399
+ mime_type=mime_type,
400
+ encoding=encoding,
401
+ compression_encoding=compression_encoding,
402
+ )
403
+
404
+ except (ValueError, FileNotFoundError) as e:
405
+ file_info = f" for file: {file_path}" if file_path is not None else ""
406
+ msg = f"Validation error in read_file{file_info}: {e}"
407
+ logger.error(msg)
408
+ return ReadFileResultFailure()
409
+ except Exception as e:
410
+ # Try to include file path in error message if available
411
+ path_info = ""
412
+ if file_path is not None:
413
+ path_info = f" for {file_path}"
414
+ elif file_path_str is not None:
415
+ path_info = f" for {file_path_str}"
416
+
417
+ msg = f"Unexpected error in read_file{path_info}: {type(e).__name__}: {e}"
418
+ logger.error(msg)
419
+ return ReadFileResultFailure()
420
+
421
+ def _read_file_content(
422
+ self, file_path: Path, request: ReadFileRequest
423
+ ) -> tuple[bytes | str, str | None, str, str | None, int]:
424
+ """Read file content and return content, encoding, mime_type, compression_encoding, and file_size."""
425
+ # Get file size
426
+ file_size = file_path.stat().st_size
427
+
428
+ # Determine MIME type and compression encoding
429
+ mime_type, compression_encoding = mimetypes.guess_type(str(file_path), strict=True)
430
+ if mime_type is None:
431
+ mime_type = "text/plain"
432
+
433
+ # Determine if file is binary
434
+ try:
435
+ is_binary_file = is_binary(str(file_path))
436
+ except Exception as e:
437
+ msg = f"binaryornot detection failed for {file_path}: {e}"
438
+ logger.warning(msg)
439
+ is_binary_file = not mime_type.startswith(
440
+ ("text/", "application/json", "application/xml", "application/yaml")
441
+ )
442
+
443
+ # Read file content
444
+ if not is_binary_file:
445
+ content, encoding = self._read_text_file(file_path, request.encoding)
446
+ else:
447
+ content, encoding = self._read_binary_file(file_path, mime_type)
448
+
449
+ return content, encoding, mime_type, compression_encoding, file_size
450
+
451
+ def _read_text_file(self, file_path: Path, requested_encoding: str) -> tuple[bytes | str, str | None]:
452
+ """Read file as text with fallback encodings."""
453
+ try:
454
+ with file_path.open(encoding=requested_encoding) as f:
455
+ return f.read(), requested_encoding
456
+ except UnicodeDecodeError:
457
+ try:
458
+ with file_path.open(encoding="utf-8") as f:
459
+ return f.read(), "utf-8"
460
+ except UnicodeDecodeError:
461
+ with file_path.open("rb") as f:
462
+ return f.read(), None
463
+
464
+ def _read_binary_file(self, file_path: Path, mime_type: str) -> tuple[bytes | str, None]:
465
+ """Read file as binary, with special handling for images."""
466
+ with file_path.open("rb") as f:
467
+ content = f.read()
468
+
469
+ if mime_type.startswith("image/"):
470
+ content = self._handle_image_content(content, file_path, mime_type)
471
+
472
+ return content, None
473
+
474
+ def _handle_image_content(self, content: bytes, file_path: Path, mime_type: str) -> str:
475
+ """Handle image content by creating previews or returning static URLs."""
476
+ # Store original bytes for preview creation
477
+ original_image_bytes = content
478
+
479
+ # Check if file is already in the static files directory
480
+ config_manager = GriptapeNodes.ConfigManager()
481
+ static_files_directory = config_manager.get_config_value("static_files_directory", default="staticfiles")
482
+ static_dir = config_manager.workspace_path / static_files_directory
483
+
484
+ try:
485
+ # Check if file is within the static files directory
486
+ file_relative_to_static = file_path.relative_to(static_dir)
487
+ # File is in static directory, construct URL directly
488
+ static_url = f"http://localhost:8124/static/{file_relative_to_static}"
489
+ msg = f"Image already in static directory, returning URL: {static_url}"
490
+ logger.debug(msg)
491
+ except ValueError:
492
+ # File is not in static directory, create small preview
493
+ from griptape_nodes.utils.image_preview import create_image_preview_from_bytes
494
+
495
+ preview_data_url = create_image_preview_from_bytes(
496
+ original_image_bytes, # type: ignore[arg-type]
497
+ max_width=200,
498
+ max_height=200,
499
+ quality=85,
500
+ image_format="WEBP",
501
+ )
502
+
503
+ if preview_data_url:
504
+ logger.debug("Image preview created (file not moved)")
505
+ return preview_data_url
506
+ # Fallback to data URL if preview creation fails
507
+ data_url = f"data:{mime_type};base64,{base64.b64encode(original_image_bytes).decode('utf-8')}"
508
+ logger.debug("Fallback to full image data URL")
509
+ return data_url
510
+ else:
511
+ return static_url
512
+
513
+ @staticmethod
514
+ def get_disk_space_info(path: Path) -> DiskSpaceInfo:
515
+ """Get disk space information for a given path.
516
+
517
+ Args:
518
+ path: The path to check disk space for.
519
+
520
+ Returns:
521
+ DiskSpaceInfo with total, used, and free disk space in bytes.
522
+ """
523
+ stat = shutil.disk_usage(path)
524
+ return DiskSpaceInfo(total=stat.total, used=stat.used, free=stat.free)
525
+
526
+ @staticmethod
527
+ def check_available_disk_space(path: Path, required_gb: float) -> bool:
528
+ """Check if there is sufficient disk space available.
529
+
530
+ Args:
531
+ path: The path to check disk space for.
532
+ required_gb: The minimum disk space required in GB.
533
+
534
+ Returns:
535
+ True if sufficient space is available, False otherwise.
536
+ """
537
+ try:
538
+ disk_info = OSManager.get_disk_space_info(path)
539
+ required_bytes = int(required_gb * 1024 * 1024 * 1024) # Convert GB to bytes
540
+ return disk_info.free >= required_bytes # noqa: TRY300
541
+ except OSError:
542
+ return False
543
+
544
+ @staticmethod
545
+ def format_disk_space_error(path: Path, exception: Exception | None = None) -> str:
546
+ """Format a user-friendly disk space error message.
547
+
548
+ Args:
549
+ path: The path where the disk space issue occurred.
550
+ exception: The original exception, if any.
551
+
552
+ Returns:
553
+ A formatted error message with disk space information.
554
+ """
555
+ try:
556
+ disk_info = OSManager.get_disk_space_info(path)
557
+ free_gb = disk_info.free / (1024**3)
558
+ used_gb = disk_info.used / (1024**3)
559
+ total_gb = disk_info.total / (1024**3)
560
+
561
+ error_msg = f"Insufficient disk space at {path}. "
562
+ error_msg += f"Available: {free_gb:.2f} GB, Used: {used_gb:.2f} GB, Total: {total_gb:.2f} GB. "
563
+
564
+ if exception:
565
+ error_msg += f"Error: {exception}"
566
+ else:
567
+ error_msg += "Please free up disk space and try again."
568
+
569
+ return error_msg # noqa: TRY300
570
+ except OSError:
571
+ return f"Could not determine disk space at {path}. Please check disk space manually."
572
+
573
+ @staticmethod
574
+ def cleanup_directory_if_needed(full_directory_path: Path, max_size_gb: float) -> bool:
575
+ """Check directory size and cleanup old files if needed.
576
+
577
+ Args:
578
+ full_directory_path: Path to the directory to check and clean
579
+ max_size_gb: Target size in GB
580
+
581
+ Returns:
582
+ True if cleanup was performed, False otherwise
583
+ """
584
+ if max_size_gb < 0:
585
+ logger.warning(
586
+ "Asked to clean up directory to be below a negative threshold. Overriding to a size of 0 GB."
587
+ )
588
+ max_size_gb = 0
589
+
590
+ # Calculate current directory size
591
+ current_size_gb = OSManager._get_directory_size_gb(full_directory_path)
592
+
593
+ if current_size_gb <= max_size_gb:
594
+ return False
595
+
596
+ logger.info(
597
+ "Directory %s size (%.1f GB) exceeds limit (%s GB). Starting cleanup...",
598
+ full_directory_path,
599
+ current_size_gb,
600
+ max_size_gb,
601
+ )
602
+
603
+ # Perform cleanup
604
+ return OSManager._cleanup_old_files(full_directory_path, max_size_gb)
605
+
606
+ @staticmethod
607
+ def _get_directory_size_gb(path: Path) -> float:
608
+ """Get total size of directory in GB.
609
+
610
+ Args:
611
+ path: Path to the directory
612
+
613
+ Returns:
614
+ Total size in GB
615
+ """
616
+ total_size = 0.0
617
+
618
+ if not path.exists():
619
+ logger.error("Directory %s does not exist. Skipping cleanup.", path)
620
+ return 0.0
621
+
622
+ for _, _, files in os.walk(path):
623
+ for f in files:
624
+ fp = path / f
625
+ if not fp.is_symlink():
626
+ total_size += fp.stat().st_size
627
+ return total_size / (1024 * 1024 * 1024) # Convert to GB
628
+
629
+ @staticmethod
630
+ def _cleanup_old_files(directory_path: Path, target_size_gb: float) -> bool:
631
+ """Remove oldest files until directory is under target size.
632
+
633
+ Args:
634
+ directory_path: Path to the directory to clean
635
+ target_size_gb: Target size in GB
636
+
637
+ Returns:
638
+ True if files were removed, False otherwise
639
+ """
640
+ if not directory_path.exists():
641
+ logger.error("Directory %s does not exist. Skipping cleanup.", directory_path)
642
+ return False
643
+
644
+ # Get all files with their modification times
645
+ files_with_times: list[tuple[Path, float]] = []
646
+
647
+ for file_path in directory_path.rglob("*"):
648
+ if file_path.is_file():
649
+ try:
650
+ mtime = file_path.stat().st_mtime
651
+ files_with_times.append((file_path, mtime))
652
+ except (OSError, FileNotFoundError) as err:
653
+ # Skip files that can't be accessed
654
+ logger.error(
655
+ "While cleaning up old files, saw file %s. File could not be accessed; skipping. Error: %s",
656
+ file_path,
657
+ err,
658
+ )
659
+ continue
660
+
661
+ if not files_with_times:
662
+ logger.error(
663
+ "Attempted to clean up files to get below a target directory size, but no suitable files were found that could be deleted."
664
+ )
665
+ return False
666
+
667
+ # Sort by modification time (oldest first)
668
+ files_with_times.sort(key=lambda x: x[1])
669
+
670
+ # Remove files until we're under the target size
671
+ removed_count = 0
672
+
673
+ for file_path, _ in files_with_times:
674
+ try:
675
+ # Delete the file.
676
+ file_path.unlink()
677
+ removed_count += 1
678
+
679
+ # Check if we're now under the target size
680
+ current_size_gb = OSManager._get_directory_size_gb(directory_path)
681
+ if current_size_gb <= target_size_gb:
682
+ # We're done!
683
+ break
684
+
685
+ except (OSError, FileNotFoundError) as err:
686
+ # Skip files that can't be deleted
687
+ logger.error(
688
+ "While cleaning up old files, attempted to delete file %s. File could not be deleted; skipping. Deletion error: %s",
689
+ file_path,
690
+ err,
691
+ )
692
+
693
+ if removed_count > 0:
694
+ final_size_gb = OSManager._get_directory_size_gb(directory_path)
695
+ logger.info(
696
+ "Cleaned up %d old files from %s. Directory size reduced to %.1f GB",
697
+ removed_count,
698
+ directory_path,
699
+ final_size_gb,
700
+ )
701
+ else:
702
+ # None deleted.
703
+ logger.error("Attempted to clean up old files from %s, but no files could be deleted.")
704
+
705
+ return removed_count > 0
@@ -32,6 +32,10 @@ class SecretsManager:
32
32
  def __init__(self, config_manager: ConfigManager, event_manager: EventManager | None = None) -> None:
33
33
  self.config_manager = config_manager
34
34
 
35
+ # So that users can access secrets directly via `os.environ`
36
+ load_dotenv(self.workspace_env_path, override=False)
37
+ load_dotenv(ENV_VAR_PATH, override=False)
38
+
35
39
  # Register all our listeners.
36
40
  if event_manager is not None:
37
41
  event_manager.assign_manager_to_request_type(GetSecretValueRequest, self.on_handle_get_secret_request)