griptape-nodes 0.60.4__py3-none-any.whl → 0.62.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/bootstrap/workflow_publishers/local_workflow_publisher.py +0 -1
- griptape_nodes/common/macro_parser/__init__.py +16 -1
- griptape_nodes/common/macro_parser/core.py +19 -7
- griptape_nodes/common/macro_parser/exceptions.py +99 -0
- griptape_nodes/common/macro_parser/formats.py +13 -4
- griptape_nodes/common/macro_parser/matching.py +5 -2
- griptape_nodes/common/macro_parser/parsing.py +48 -8
- griptape_nodes/common/macro_parser/resolution.py +23 -5
- griptape_nodes/common/project_templates/__init__.py +49 -0
- griptape_nodes/common/project_templates/default_project_template.py +87 -0
- griptape_nodes/common/project_templates/defaults/README.md +36 -0
- griptape_nodes/common/project_templates/directory.py +67 -0
- griptape_nodes/common/project_templates/loader.py +342 -0
- griptape_nodes/common/project_templates/project.py +252 -0
- griptape_nodes/common/project_templates/situation.py +143 -0
- griptape_nodes/common/project_templates/validation.py +140 -0
- griptape_nodes/exe_types/core_types.py +36 -3
- griptape_nodes/exe_types/node_types.py +4 -2
- griptape_nodes/exe_types/param_components/progress_bar_component.py +57 -0
- griptape_nodes/exe_types/param_types/parameter_audio.py +243 -0
- griptape_nodes/exe_types/param_types/parameter_image.py +243 -0
- griptape_nodes/exe_types/param_types/parameter_three_d.py +215 -0
- griptape_nodes/exe_types/param_types/parameter_video.py +243 -0
- griptape_nodes/node_library/workflow_registry.py +1 -1
- griptape_nodes/retained_mode/events/execution_events.py +41 -0
- griptape_nodes/retained_mode/events/node_events.py +90 -1
- griptape_nodes/retained_mode/events/os_events.py +108 -0
- griptape_nodes/retained_mode/events/parameter_events.py +1 -1
- griptape_nodes/retained_mode/events/project_events.py +528 -0
- griptape_nodes/retained_mode/events/workflow_events.py +19 -1
- griptape_nodes/retained_mode/griptape_nodes.py +9 -1
- griptape_nodes/retained_mode/managers/agent_manager.py +18 -24
- griptape_nodes/retained_mode/managers/event_manager.py +6 -9
- griptape_nodes/retained_mode/managers/flow_manager.py +63 -0
- griptape_nodes/retained_mode/managers/library_manager.py +55 -42
- griptape_nodes/retained_mode/managers/mcp_manager.py +14 -6
- griptape_nodes/retained_mode/managers/node_manager.py +232 -0
- griptape_nodes/retained_mode/managers/os_manager.py +399 -6
- griptape_nodes/retained_mode/managers/project_manager.py +1067 -0
- griptape_nodes/retained_mode/managers/settings.py +6 -0
- griptape_nodes/retained_mode/managers/sync_manager.py +4 -1
- griptape_nodes/retained_mode/managers/workflow_manager.py +8 -79
- griptape_nodes/traits/button.py +19 -0
- {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/METADATA +5 -3
- {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/RECORD +47 -32
- {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/WHEEL +1 -1
- {griptape_nodes-0.60.4.dist-info → griptape_nodes-0.62.0.dist-info}/entry_points.txt +0 -0
|
@@ -15,6 +15,12 @@ from rich.console import Console
|
|
|
15
15
|
from griptape_nodes.retained_mode.events.app_events import AppInitializationComplete
|
|
16
16
|
from griptape_nodes.retained_mode.events.base_events import ResultDetails, ResultPayload
|
|
17
17
|
from griptape_nodes.retained_mode.events.os_events import (
|
|
18
|
+
CopyFileRequest,
|
|
19
|
+
CopyFileResultFailure,
|
|
20
|
+
CopyFileResultSuccess,
|
|
21
|
+
CopyTreeRequest,
|
|
22
|
+
CopyTreeResultFailure,
|
|
23
|
+
CopyTreeResultSuccess,
|
|
18
24
|
CreateFileRequest,
|
|
19
25
|
CreateFileResultFailure,
|
|
20
26
|
CreateFileResultSuccess,
|
|
@@ -73,6 +79,24 @@ class FileContentResult(NamedTuple):
|
|
|
73
79
|
file_size: int
|
|
74
80
|
|
|
75
81
|
|
|
82
|
+
@dataclass
|
|
83
|
+
class CopyTreeValidationResult:
|
|
84
|
+
"""Result from validating copy tree paths."""
|
|
85
|
+
|
|
86
|
+
source_normalized: str
|
|
87
|
+
dest_normalized: str
|
|
88
|
+
source_path: Path
|
|
89
|
+
destination_path: Path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CopyTreeStats:
|
|
94
|
+
"""Statistics from copying a directory tree."""
|
|
95
|
+
|
|
96
|
+
files_copied: int
|
|
97
|
+
total_bytes_copied: int
|
|
98
|
+
|
|
99
|
+
|
|
76
100
|
class OSManager:
|
|
77
101
|
"""A class to manage OS-level scenarios.
|
|
78
102
|
|
|
@@ -105,6 +129,14 @@ class OSManager:
|
|
|
105
129
|
request_type=WriteFileRequest, callback=self.on_write_file_request
|
|
106
130
|
)
|
|
107
131
|
|
|
132
|
+
event_manager.assign_manager_to_request_type(
|
|
133
|
+
request_type=CopyTreeRequest, callback=self.on_copy_tree_request
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
event_manager.assign_manager_to_request_type(
|
|
137
|
+
request_type=CopyFileRequest, callback=self.on_copy_file_request
|
|
138
|
+
)
|
|
139
|
+
|
|
108
140
|
# Register for app initialization event to setup system resources
|
|
109
141
|
event_manager.add_listener_to_app_event(AppInitializationComplete, self.on_app_initialization_complete)
|
|
110
142
|
|
|
@@ -123,7 +155,49 @@ class OSManager:
|
|
|
123
155
|
"""
|
|
124
156
|
# Expand environment variables first, then tilde
|
|
125
157
|
expanded_vars = os.path.expandvars(path_str)
|
|
126
|
-
return Path(expanded_vars).expanduser()
|
|
158
|
+
return self.resolve_path_safely(Path(expanded_vars).expanduser())
|
|
159
|
+
|
|
160
|
+
def resolve_path_safely(self, path: Path) -> Path:
|
|
161
|
+
"""Resolve a path consistently across platforms.
|
|
162
|
+
|
|
163
|
+
Unlike Path.resolve() which behaves differently on Windows vs Unix
|
|
164
|
+
for non-existent paths, this method provides consistent behavior:
|
|
165
|
+
- Converts relative paths to absolute (using CWD as base)
|
|
166
|
+
- Normalizes path separators and removes . and ..
|
|
167
|
+
- Does NOT resolve symlinks if path doesn't exist
|
|
168
|
+
- Does NOT change path based on CWD for absolute paths
|
|
169
|
+
|
|
170
|
+
Use this instead of .resolve() when:
|
|
171
|
+
- Path might not exist (file creation, validation, user input)
|
|
172
|
+
- You need consistent cross-platform comparison
|
|
173
|
+
- You're about to create the file/directory
|
|
174
|
+
|
|
175
|
+
Use .resolve() when:
|
|
176
|
+
- Path definitely exists and you need symlink resolution
|
|
177
|
+
- You're checking actual file locations
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
path: Path to resolve (relative or absolute, existing or not)
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Absolute, normalized Path object
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
# Relative path
|
|
187
|
+
resolve_path_safely(Path("relative/file.txt"))
|
|
188
|
+
→ Path("/current/dir/relative/file.txt")
|
|
189
|
+
|
|
190
|
+
# Absolute non-existent path (Windows safe)
|
|
191
|
+
resolve_path_safely(Path("/abs/nonexistent/path"))
|
|
192
|
+
→ Path("/abs/nonexistent/path") # NOT resolved relative to CWD
|
|
193
|
+
"""
|
|
194
|
+
# Convert to absolute if relative
|
|
195
|
+
if not path.is_absolute():
|
|
196
|
+
path = Path.cwd() / path
|
|
197
|
+
|
|
198
|
+
# Normalize (remove . and .., collapse slashes) without resolving symlinks
|
|
199
|
+
# This works consistently even for non-existent paths on Windows
|
|
200
|
+
return Path(os.path.normpath(path))
|
|
127
201
|
|
|
128
202
|
def _resolve_file_path(self, path_str: str, *, workspace_only: bool = False) -> Path:
|
|
129
203
|
"""Resolve a file path, handling absolute, relative, and tilde paths.
|
|
@@ -140,7 +214,7 @@ class OSManager:
|
|
|
140
214
|
# Expand tilde and environment variables for absolute paths or paths starting with ~
|
|
141
215
|
return self._expand_path(path_str)
|
|
142
216
|
# Both workspace and system-wide modes resolve relative to current directory
|
|
143
|
-
return (self._get_workspace_path() / path_str)
|
|
217
|
+
return self.resolve_path_safely(self._get_workspace_path() / path_str)
|
|
144
218
|
except (ValueError, RuntimeError):
|
|
145
219
|
if workspace_only:
|
|
146
220
|
msg = f"Path '{path_str}' not found, using workspace directory: {self._get_workspace_path()}"
|
|
@@ -161,8 +235,11 @@ class OSManager:
|
|
|
161
235
|
workspace = GriptapeNodes.ConfigManager().workspace_path
|
|
162
236
|
|
|
163
237
|
# Ensure both paths are resolved for comparison
|
|
238
|
+
# Both path and workspace should use .resolve() to follow symlinks consistently
|
|
239
|
+
# (e.g., /var -> /private/var on macOS). Even if path doesn't exist yet,
|
|
240
|
+
# .resolve() will resolve parent directories and symlinks in the path.
|
|
164
241
|
path = path.resolve()
|
|
165
|
-
workspace = workspace.resolve()
|
|
242
|
+
workspace = workspace.resolve() # Workspace should always exist
|
|
166
243
|
|
|
167
244
|
msg = f"Validating path: {path} against workspace: {workspace}"
|
|
168
245
|
logger.debug(msg)
|
|
@@ -185,6 +262,9 @@ class OSManager:
|
|
|
185
262
|
need the \\?\ prefix to work correctly. This method transparently adds
|
|
186
263
|
the prefix when needed on Windows.
|
|
187
264
|
|
|
265
|
+
Note: This method assumes the path exists or will exist. For non-existent
|
|
266
|
+
paths that need cross-platform normalization, use resolve_path_safely() first.
|
|
267
|
+
|
|
188
268
|
Args:
|
|
189
269
|
path: Path object to convert to string
|
|
190
270
|
|
|
@@ -411,7 +491,7 @@ class OSManager:
|
|
|
411
491
|
directory = self._expand_path(request.directory_path)
|
|
412
492
|
else:
|
|
413
493
|
# Both workspace and system-wide modes resolve relative to current directory
|
|
414
|
-
directory = (self._get_workspace_path() / request.directory_path)
|
|
494
|
+
directory = self.resolve_path_safely(self._get_workspace_path() / request.directory_path)
|
|
415
495
|
|
|
416
496
|
# Check if directory exists
|
|
417
497
|
if not directory.exists():
|
|
@@ -757,6 +837,30 @@ class OSManager:
|
|
|
757
837
|
result_details=f"File written successfully: {file_path}",
|
|
758
838
|
)
|
|
759
839
|
|
|
840
|
+
def _copy_file(self, src_path: Path, dest_path: Path) -> int:
|
|
841
|
+
"""Copy a single file from source to destination with platform path normalization.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
src_path: Source file path (Path object)
|
|
845
|
+
dest_path: Destination file path (Path object)
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
Number of bytes copied
|
|
849
|
+
|
|
850
|
+
Raises:
|
|
851
|
+
OSError: If copy operation fails
|
|
852
|
+
PermissionError: If permission denied
|
|
853
|
+
"""
|
|
854
|
+
# Normalize both paths for platform (handles Windows long paths)
|
|
855
|
+
src_normalized = self.normalize_path_for_platform(src_path)
|
|
856
|
+
dest_normalized = self.normalize_path_for_platform(dest_path)
|
|
857
|
+
|
|
858
|
+
# Copy file preserving metadata
|
|
859
|
+
shutil.copy2(src_normalized, dest_normalized)
|
|
860
|
+
|
|
861
|
+
# Return size of copied file
|
|
862
|
+
return os.path.getsize(src_normalized) # noqa: PTH202
|
|
863
|
+
|
|
760
864
|
def _write_file_content(self, normalized_path: str, content: str | bytes, encoding: str, *, append: bool) -> int:
|
|
761
865
|
"""Write content to a file and return bytes written.
|
|
762
866
|
|
|
@@ -1000,9 +1104,9 @@ class OSManager:
|
|
|
1000
1104
|
|
|
1001
1105
|
# Resolve path - if absolute, use as-is; if relative, align to workspace
|
|
1002
1106
|
if is_absolute:
|
|
1003
|
-
file_path = Path(full_path_str)
|
|
1107
|
+
file_path = self.resolve_path_safely(Path(full_path_str))
|
|
1004
1108
|
else:
|
|
1005
|
-
file_path = (self._get_workspace_path() / full_path_str)
|
|
1109
|
+
file_path = self.resolve_path_safely(self._get_workspace_path() / full_path_str)
|
|
1006
1110
|
|
|
1007
1111
|
# Check if it already exists - warn but treat as success
|
|
1008
1112
|
if file_path.exists():
|
|
@@ -1135,6 +1239,295 @@ class OSManager:
|
|
|
1135
1239
|
result_details=ResultDetails(message=details, level=logging.INFO),
|
|
1136
1240
|
)
|
|
1137
1241
|
|
|
1242
|
+
def on_copy_file_request(self, request: CopyFileRequest) -> ResultPayload: # noqa: PLR0911, C901
|
|
1243
|
+
"""Handle a request to copy a single file."""
|
|
1244
|
+
# Resolve source path
|
|
1245
|
+
try:
|
|
1246
|
+
source_path = self._resolve_file_path(request.source_path, workspace_only=False)
|
|
1247
|
+
source_normalized = self.normalize_path_for_platform(source_path)
|
|
1248
|
+
except (ValueError, RuntimeError) as e:
|
|
1249
|
+
msg = f"Invalid source path: {e}"
|
|
1250
|
+
logger.error(msg)
|
|
1251
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1252
|
+
|
|
1253
|
+
# Check if source exists
|
|
1254
|
+
if not Path(source_normalized).exists():
|
|
1255
|
+
msg = f"Source file does not exist: {source_path}"
|
|
1256
|
+
logger.error(msg)
|
|
1257
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=msg)
|
|
1258
|
+
|
|
1259
|
+
# Check if source is a file (not a directory)
|
|
1260
|
+
if not Path(source_normalized).is_file():
|
|
1261
|
+
msg = f"Source path is not a file: {source_path}"
|
|
1262
|
+
logger.error(msg)
|
|
1263
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1264
|
+
|
|
1265
|
+
# Resolve destination path
|
|
1266
|
+
try:
|
|
1267
|
+
destination_path = self._resolve_file_path(request.destination_path, workspace_only=False)
|
|
1268
|
+
dest_normalized = self.normalize_path_for_platform(destination_path)
|
|
1269
|
+
except (ValueError, RuntimeError) as e:
|
|
1270
|
+
msg = f"Invalid destination path: {e}"
|
|
1271
|
+
logger.error(msg)
|
|
1272
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1273
|
+
|
|
1274
|
+
# Check if destination already exists (unless overwrite is True)
|
|
1275
|
+
if Path(dest_normalized).exists() and not request.overwrite:
|
|
1276
|
+
msg = f"Destination file already exists: {destination_path}"
|
|
1277
|
+
logger.error(msg)
|
|
1278
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1279
|
+
|
|
1280
|
+
# Create parent directory if it doesn't exist
|
|
1281
|
+
dest_parent = Path(dest_normalized).parent
|
|
1282
|
+
if not dest_parent.exists():
|
|
1283
|
+
try:
|
|
1284
|
+
dest_parent.mkdir(parents=True)
|
|
1285
|
+
except PermissionError as e:
|
|
1286
|
+
msg = f"Permission denied creating parent directory {dest_parent}: {e}"
|
|
1287
|
+
logger.error(msg)
|
|
1288
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
1289
|
+
except OSError as e:
|
|
1290
|
+
msg = f"I/O error creating parent directory {dest_parent}: {e}"
|
|
1291
|
+
logger.error(msg)
|
|
1292
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
1293
|
+
|
|
1294
|
+
# Copy the file
|
|
1295
|
+
try:
|
|
1296
|
+
bytes_copied = self._copy_file(source_path, destination_path)
|
|
1297
|
+
except PermissionError as e:
|
|
1298
|
+
msg = f"Permission denied copying {source_path} to {destination_path}: {e}"
|
|
1299
|
+
logger.error(msg)
|
|
1300
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
1301
|
+
except OSError as e:
|
|
1302
|
+
if "No space left" in str(e) or "Disk full" in str(e):
|
|
1303
|
+
msg = f"Disk full copying {source_path} to {destination_path}: {e}"
|
|
1304
|
+
logger.error(msg)
|
|
1305
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg)
|
|
1306
|
+
|
|
1307
|
+
msg = f"I/O error copying {source_path} to {destination_path}: {e}"
|
|
1308
|
+
logger.error(msg)
|
|
1309
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
1310
|
+
except Exception as e:
|
|
1311
|
+
msg = f"Unexpected error copying {source_path} to {destination_path}: {type(e).__name__}: {e}"
|
|
1312
|
+
logger.error(msg)
|
|
1313
|
+
return CopyFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
|
|
1314
|
+
|
|
1315
|
+
# SUCCESS PATH
|
|
1316
|
+
return CopyFileResultSuccess(
|
|
1317
|
+
source_path=str(source_path),
|
|
1318
|
+
destination_path=str(destination_path),
|
|
1319
|
+
bytes_copied=bytes_copied,
|
|
1320
|
+
result_details=f"File copied successfully: {source_path} -> {destination_path}",
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
def _validate_copy_tree_paths(
|
|
1324
|
+
self, source_str: str, dest_str: str, *, dirs_exist_ok: bool
|
|
1325
|
+
) -> CopyTreeValidationResult | CopyTreeResultFailure:
|
|
1326
|
+
"""Validate and normalize source and destination paths for copy tree operation.
|
|
1327
|
+
|
|
1328
|
+
Returns:
|
|
1329
|
+
CopyTreeValidationResult on success, CopyTreeResultFailure on validation failure
|
|
1330
|
+
"""
|
|
1331
|
+
# Resolve and normalize source path
|
|
1332
|
+
try:
|
|
1333
|
+
source_path = self._resolve_file_path(source_str, workspace_only=False)
|
|
1334
|
+
source_normalized = self.normalize_path_for_platform(source_path)
|
|
1335
|
+
except (ValueError, RuntimeError) as e:
|
|
1336
|
+
msg = f"Invalid source path: {e}"
|
|
1337
|
+
logger.error(msg)
|
|
1338
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1339
|
+
|
|
1340
|
+
# Check if source exists
|
|
1341
|
+
if not Path(source_normalized).exists():
|
|
1342
|
+
msg = f"Source path does not exist: {source_path}"
|
|
1343
|
+
logger.error(msg)
|
|
1344
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=msg)
|
|
1345
|
+
|
|
1346
|
+
# Check if source is a directory
|
|
1347
|
+
if not Path(source_normalized).is_dir():
|
|
1348
|
+
msg = f"Source path is not a directory: {source_path}"
|
|
1349
|
+
logger.error(msg)
|
|
1350
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1351
|
+
|
|
1352
|
+
# Resolve and normalize destination path
|
|
1353
|
+
try:
|
|
1354
|
+
destination_path = self._resolve_file_path(dest_str, workspace_only=False)
|
|
1355
|
+
dest_normalized = self.normalize_path_for_platform(destination_path)
|
|
1356
|
+
except (ValueError, RuntimeError) as e:
|
|
1357
|
+
msg = f"Invalid destination path: {e}"
|
|
1358
|
+
logger.error(msg)
|
|
1359
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1360
|
+
|
|
1361
|
+
# Check if destination already exists (unless dirs_exist_ok is True)
|
|
1362
|
+
if Path(dest_normalized).exists() and not dirs_exist_ok:
|
|
1363
|
+
msg = f"Destination path already exists: {destination_path}"
|
|
1364
|
+
logger.error(msg)
|
|
1365
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
1366
|
+
|
|
1367
|
+
return CopyTreeValidationResult(
|
|
1368
|
+
source_normalized=source_normalized,
|
|
1369
|
+
dest_normalized=dest_normalized,
|
|
1370
|
+
source_path=source_path,
|
|
1371
|
+
destination_path=destination_path,
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
def _copy_directory_tree( # noqa: PLR0912, C901
|
|
1375
|
+
self,
|
|
1376
|
+
source_normalized: str,
|
|
1377
|
+
dest_normalized: str,
|
|
1378
|
+
*,
|
|
1379
|
+
symlinks: bool,
|
|
1380
|
+
ignore_dangling_symlinks: bool,
|
|
1381
|
+
ignore_patterns: list[str] | None = None,
|
|
1382
|
+
) -> CopyTreeStats:
|
|
1383
|
+
"""Copy directory tree from source to destination.
|
|
1384
|
+
|
|
1385
|
+
Args:
|
|
1386
|
+
source_normalized: Normalized source path
|
|
1387
|
+
dest_normalized: Normalized destination path
|
|
1388
|
+
symlinks: If True, copy symbolic links as links
|
|
1389
|
+
ignore_dangling_symlinks: If True, ignore dangling symlinks
|
|
1390
|
+
ignore_patterns: List of glob patterns to ignore (e.g., ["__pycache__", "*.pyc"])
|
|
1391
|
+
|
|
1392
|
+
Returns:
|
|
1393
|
+
CopyTreeStats with files copied and bytes copied
|
|
1394
|
+
|
|
1395
|
+
Raises:
|
|
1396
|
+
OSError: If copy operation fails
|
|
1397
|
+
PermissionError: If permission denied
|
|
1398
|
+
"""
|
|
1399
|
+
from fnmatch import fnmatch
|
|
1400
|
+
|
|
1401
|
+
files_copied = 0
|
|
1402
|
+
total_bytes_copied = 0
|
|
1403
|
+
ignore_patterns = ignore_patterns or []
|
|
1404
|
+
|
|
1405
|
+
def should_ignore(name: str) -> bool:
|
|
1406
|
+
"""Check if a file/directory name matches any ignore pattern."""
|
|
1407
|
+
return any(fnmatch(name, pattern) for pattern in ignore_patterns)
|
|
1408
|
+
|
|
1409
|
+
# Create destination directory if it doesn't exist
|
|
1410
|
+
dest_path_obj = Path(dest_normalized)
|
|
1411
|
+
if not dest_path_obj.exists():
|
|
1412
|
+
dest_path_obj.mkdir(parents=True)
|
|
1413
|
+
|
|
1414
|
+
# Walk through source directory and copy files/directories
|
|
1415
|
+
for root, dirs, files in os.walk(source_normalized):
|
|
1416
|
+
# Calculate relative path from source
|
|
1417
|
+
root_path = Path(root)
|
|
1418
|
+
source_path_obj = Path(source_normalized)
|
|
1419
|
+
rel_path = root_path.relative_to(source_path_obj)
|
|
1420
|
+
|
|
1421
|
+
# Create corresponding directory in destination
|
|
1422
|
+
if str(rel_path) != ".":
|
|
1423
|
+
dest_dir = dest_path_obj / rel_path
|
|
1424
|
+
else:
|
|
1425
|
+
dest_dir = dest_path_obj
|
|
1426
|
+
|
|
1427
|
+
# Filter out ignored directories and create remaining ones
|
|
1428
|
+
dirs_to_remove = []
|
|
1429
|
+
for dir_name in dirs:
|
|
1430
|
+
if should_ignore(dir_name):
|
|
1431
|
+
dirs_to_remove.append(dir_name)
|
|
1432
|
+
continue
|
|
1433
|
+
|
|
1434
|
+
src_dir = root_path / dir_name
|
|
1435
|
+
dst_dir = dest_dir / dir_name
|
|
1436
|
+
|
|
1437
|
+
# Handle symlinks if requested
|
|
1438
|
+
if src_dir.is_symlink():
|
|
1439
|
+
if symlinks:
|
|
1440
|
+
link_target = src_dir.readlink()
|
|
1441
|
+
dst_dir.symlink_to(link_target)
|
|
1442
|
+
continue
|
|
1443
|
+
|
|
1444
|
+
if not dst_dir.exists():
|
|
1445
|
+
dst_dir.mkdir(parents=True)
|
|
1446
|
+
|
|
1447
|
+
# Remove ignored directories from dirs list to prevent os.walk from descending into them
|
|
1448
|
+
for dir_name in dirs_to_remove:
|
|
1449
|
+
dirs.remove(dir_name)
|
|
1450
|
+
|
|
1451
|
+
# Copy files
|
|
1452
|
+
for file_name in files:
|
|
1453
|
+
# Skip ignored files
|
|
1454
|
+
if should_ignore(file_name):
|
|
1455
|
+
continue
|
|
1456
|
+
|
|
1457
|
+
src_file = root_path / file_name
|
|
1458
|
+
dst_file = dest_dir / file_name
|
|
1459
|
+
|
|
1460
|
+
# Handle symlinks if requested
|
|
1461
|
+
if src_file.is_symlink():
|
|
1462
|
+
if symlinks:
|
|
1463
|
+
try:
|
|
1464
|
+
link_target = src_file.readlink()
|
|
1465
|
+
dst_file.symlink_to(link_target)
|
|
1466
|
+
except OSError:
|
|
1467
|
+
if not ignore_dangling_symlinks:
|
|
1468
|
+
raise
|
|
1469
|
+
continue
|
|
1470
|
+
|
|
1471
|
+
# Copy file
|
|
1472
|
+
bytes_copied = self._copy_file(src_file, dst_file)
|
|
1473
|
+
files_copied += 1
|
|
1474
|
+
total_bytes_copied += bytes_copied
|
|
1475
|
+
|
|
1476
|
+
return CopyTreeStats(files_copied=files_copied, total_bytes_copied=total_bytes_copied)
|
|
1477
|
+
|
|
1478
|
+
def on_copy_tree_request(self, request: CopyTreeRequest) -> ResultPayload:
|
|
1479
|
+
"""Handle a request to copy a directory tree."""
|
|
1480
|
+
# Validate paths
|
|
1481
|
+
validation_result = self._validate_copy_tree_paths(
|
|
1482
|
+
request.source_path,
|
|
1483
|
+
request.destination_path,
|
|
1484
|
+
dirs_exist_ok=request.dirs_exist_ok,
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
if isinstance(validation_result, CopyTreeResultFailure):
|
|
1488
|
+
return validation_result
|
|
1489
|
+
|
|
1490
|
+
source_normalized = validation_result.source_normalized
|
|
1491
|
+
dest_normalized = validation_result.dest_normalized
|
|
1492
|
+
source_path = validation_result.source_path
|
|
1493
|
+
destination_path = validation_result.destination_path
|
|
1494
|
+
|
|
1495
|
+
# Copy directory tree
|
|
1496
|
+
try:
|
|
1497
|
+
stats = self._copy_directory_tree(
|
|
1498
|
+
source_normalized,
|
|
1499
|
+
dest_normalized,
|
|
1500
|
+
symlinks=request.symlinks,
|
|
1501
|
+
ignore_dangling_symlinks=request.ignore_dangling_symlinks,
|
|
1502
|
+
ignore_patterns=request.ignore_patterns,
|
|
1503
|
+
)
|
|
1504
|
+
except PermissionError as e:
|
|
1505
|
+
msg = f"Permission denied copying {source_path} to {destination_path}: {e}"
|
|
1506
|
+
logger.error(msg)
|
|
1507
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
1508
|
+
except OSError as e:
|
|
1509
|
+
if "No space left" in str(e) or "Disk full" in str(e):
|
|
1510
|
+
msg = f"Disk full copying {source_path} to {destination_path}: {e}"
|
|
1511
|
+
logger.error(msg)
|
|
1512
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg)
|
|
1513
|
+
|
|
1514
|
+
msg = f"I/O error copying {source_path} to {destination_path}: {e}"
|
|
1515
|
+
logger.error(msg)
|
|
1516
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
1517
|
+
except Exception as e:
|
|
1518
|
+
msg = f"Unexpected error copying {source_path} to {destination_path}: {type(e).__name__}: {e}"
|
|
1519
|
+
logger.error(msg)
|
|
1520
|
+
return CopyTreeResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
|
|
1521
|
+
|
|
1522
|
+
# SUCCESS PATH
|
|
1523
|
+
return CopyTreeResultSuccess(
|
|
1524
|
+
source_path=str(source_path),
|
|
1525
|
+
destination_path=str(destination_path),
|
|
1526
|
+
files_copied=stats.files_copied,
|
|
1527
|
+
total_bytes_copied=stats.total_bytes_copied,
|
|
1528
|
+
result_details=f"Directory tree copied successfully: {source_path} -> {destination_path}",
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1138
1531
|
def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
|
|
1139
1532
|
"""Handle app initialization complete event by registering system resources."""
|
|
1140
1533
|
self._register_system_resources()
|