griptape-nodes 0.42.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 (132) hide show
  1. griptape_nodes/__init__.py +0 -0
  2. griptape_nodes/app/.python-version +0 -0
  3. griptape_nodes/app/__init__.py +1 -6
  4. griptape_nodes/app/api.py +199 -0
  5. griptape_nodes/app/app.py +140 -225
  6. griptape_nodes/app/watch.py +1 -1
  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 +2 -1
  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 +0 -0
  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 +41 -12
  33. griptape_nodes/machines/fsm.py +16 -2
  34. griptape_nodes/machines/node_resolution.py +0 -0
  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 +1 -1
  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 +0 -0
  46. griptape_nodes/retained_mode/events/app_events.py +6 -2
  47. griptape_nodes/retained_mode/events/arbitrary_python_events.py +0 -0
  48. griptape_nodes/retained_mode/events/base_events.py +6 -6
  49. griptape_nodes/retained_mode/events/config_events.py +0 -0
  50. griptape_nodes/retained_mode/events/connection_events.py +0 -0
  51. griptape_nodes/retained_mode/events/context_events.py +0 -0
  52. griptape_nodes/retained_mode/events/execution_events.py +0 -0
  53. griptape_nodes/retained_mode/events/flow_events.py +0 -0
  54. griptape_nodes/retained_mode/events/generate_request_payload_schemas.py +0 -0
  55. griptape_nodes/retained_mode/events/library_events.py +2 -2
  56. griptape_nodes/retained_mode/events/logger_events.py +0 -0
  57. griptape_nodes/retained_mode/events/node_events.py +0 -0
  58. griptape_nodes/retained_mode/events/object_events.py +0 -0
  59. griptape_nodes/retained_mode/events/os_events.py +104 -2
  60. griptape_nodes/retained_mode/events/parameter_events.py +0 -0
  61. griptape_nodes/retained_mode/events/payload_registry.py +0 -0
  62. griptape_nodes/retained_mode/events/secrets_events.py +0 -0
  63. griptape_nodes/retained_mode/events/static_file_events.py +0 -0
  64. griptape_nodes/retained_mode/events/validation_events.py +0 -0
  65. griptape_nodes/retained_mode/events/workflow_events.py +0 -0
  66. griptape_nodes/retained_mode/griptape_nodes.py +43 -40
  67. griptape_nodes/retained_mode/managers/__init__.py +0 -0
  68. griptape_nodes/retained_mode/managers/agent_manager.py +48 -22
  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 +0 -0
  73. griptape_nodes/retained_mode/managers/event_manager.py +0 -0
  74. griptape_nodes/retained_mode/managers/flow_manager.py +2 -0
  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 +144 -39
  88. griptape_nodes/retained_mode/managers/node_manager.py +86 -72
  89. griptape_nodes/retained_mode/managers/object_manager.py +0 -0
  90. griptape_nodes/retained_mode/managers/operation_manager.py +0 -0
  91. griptape_nodes/retained_mode/managers/os_manager.py +517 -12
  92. griptape_nodes/retained_mode/managers/secrets_manager.py +0 -0
  93. griptape_nodes/retained_mode/managers/session_manager.py +0 -0
  94. griptape_nodes/retained_mode/managers/settings.py +0 -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 +199 -2
  98. griptape_nodes/retained_mode/retained_mode.py +0 -0
  99. griptape_nodes/retained_mode/utils/__init__.py +0 -0
  100. griptape_nodes/retained_mode/utils/engine_identity.py +0 -0
  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.42.0.dist-info → griptape_nodes-0.43.0.dist-info}/entry_points.txt +1 -0
  128. griptape_nodes/app/app_sessions.py +0 -554
  129. griptape_nodes-0.42.0.dist-info/METADATA +0 -78
  130. griptape_nodes-0.42.0.dist-info/RECORD +0 -113
  131. griptape_nodes-0.42.0.dist-info/WHEEL +0 -4
  132. griptape_nodes-0.42.0.dist-info/licenses/LICENSE +0 -201
@@ -1,27 +1,38 @@
1
- import logging
1
+ import base64
2
+ import mimetypes
2
3
  import os
3
4
  import shutil
4
5
  import subprocess
5
6
  import sys
7
+ from dataclasses import dataclass
6
8
  from pathlib import Path
7
- from typing import Any, NamedTuple
9
+ from typing import Any
8
10
 
11
+ from binaryornot.check import is_binary
9
12
  from rich.console import Console
10
13
 
11
14
  from griptape_nodes.retained_mode.events.base_events import ResultPayload
12
15
  from griptape_nodes.retained_mode.events.os_events import (
16
+ FileSystemEntry,
17
+ ListDirectoryRequest,
18
+ ListDirectoryResultFailure,
19
+ ListDirectoryResultSuccess,
13
20
  OpenAssociatedFileRequest,
14
21
  OpenAssociatedFileResultFailure,
15
22
  OpenAssociatedFileResultSuccess,
23
+ ReadFileRequest,
24
+ ReadFileResultFailure,
25
+ ReadFileResultSuccess,
16
26
  )
27
+ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes, logger
17
28
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
18
29
 
19
30
  console = Console()
20
- logger = logging.getLogger("griptape_nodes")
21
31
 
22
32
 
23
- class DiskSpaceInfo(NamedTuple):
24
- """Disk space information in bytes."""
33
+ @dataclass
34
+ class DiskSpaceInfo:
35
+ """Information about disk space usage."""
25
36
 
26
37
  total: int
27
38
  used: int
@@ -40,6 +51,127 @@ class OSManager:
40
51
  event_manager.assign_manager_to_request_type(
41
52
  request_type=OpenAssociatedFileRequest, callback=self.on_open_associated_file_request
42
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
43
175
 
44
176
  @staticmethod
45
177
  def platform() -> str:
@@ -72,12 +204,43 @@ class OSManager:
72
204
  sys.stdout.flush() # Recommended here https://docs.python.org/3/library/os.html#os.execvpe
73
205
  os.execvp(args[0], args) # noqa: S606
74
206
 
75
- 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
+
76
238
  # Sanitize and validate the file path
77
239
  try:
78
- 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)
79
242
  except (ValueError, RuntimeError):
80
- details = f"Invalid file path: '{request.path_to_file}'"
243
+ details = f"Invalid file path: '{file_path_str}'"
81
244
  logger.info(details)
82
245
  return OpenAssociatedFileResultFailure()
83
246
 
@@ -90,12 +253,12 @@ class OSManager:
90
253
 
91
254
  try:
92
255
  platform_name = sys.platform
93
- if self.is_windows:
256
+ if self.is_windows():
94
257
  # Linter complains but this is the recommended way on Windows
95
258
  # We can ignore this warning as we've validated the path
96
259
  os.startfile(str(path)) # noqa: S606 # pyright: ignore[reportAttributeAccessIssue]
97
260
  logger.info("Started file on Windows: %s", path)
98
- elif self.is_mac:
261
+ elif self.is_mac():
99
262
  # On macOS, open should be in a standard location
100
263
  subprocess.run( # noqa: S603
101
264
  ["/usr/bin/open", str(path)],
@@ -104,7 +267,7 @@ class OSManager:
104
267
  text=True,
105
268
  )
106
269
  logger.info("Started file on macOS: %s", path)
107
- elif self.is_linux:
270
+ elif self.is_linux():
108
271
  # Use full path to xdg-open to satisfy linter
109
272
  # Common locations for xdg-open:
110
273
  xdg_paths = ["/usr/bin/xdg-open", "/bin/xdg-open", "/usr/local/bin/xdg-open"]
@@ -139,6 +302,214 @@ class OSManager:
139
302
  logger.error("Exception occurred when trying to open file: %s", type(e).__name__)
140
303
  return OpenAssociatedFileResultFailure()
141
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
+
142
513
  @staticmethod
143
514
  def get_disk_space_info(path: Path) -> DiskSpaceInfo:
144
515
  """Get disk space information for a given path.
@@ -197,4 +568,138 @@ class OSManager:
197
568
 
198
569
  return error_msg # noqa: TRY300
199
570
  except OSError:
200
- return f"Disk space error at {path}. Unable to retrieve disk space information."
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
File without changes
File without changes
File without changes
@@ -15,7 +15,7 @@ from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes, Version
15
15
  if TYPE_CHECKING:
16
16
  from griptape_nodes.node_library.library_registry import LibrarySchema
17
17
  from griptape_nodes.retained_mode.managers.event_manager import EventManager
18
- from griptape_nodes.retained_mode.managers.library_manager import LibraryManager
18
+ from griptape_nodes.retained_mode.managers.library_lifecycle.library_status import LibraryStatus
19
19
 
20
20
  logger = logging.getLogger("griptape_nodes")
21
21
 
@@ -24,7 +24,7 @@ class LibraryVersionCompatibilityIssue(NamedTuple):
24
24
  """Represents a library version compatibility issue found in a library."""
25
25
 
26
26
  message: str
27
- severity: LibraryManager.LibraryStatus
27
+ severity: LibraryStatus
28
28
 
29
29
 
30
30
  class LibraryVersionCompatibilityCheck(ABC):