griptape-nodes 0.59.2__py3-none-any.whl → 0.60.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/common/macro_parser/__init__.py +28 -0
- griptape_nodes/common/macro_parser/core.py +230 -0
- griptape_nodes/common/macro_parser/exceptions.py +23 -0
- griptape_nodes/common/macro_parser/formats.py +170 -0
- griptape_nodes/common/macro_parser/matching.py +134 -0
- griptape_nodes/common/macro_parser/parsing.py +172 -0
- griptape_nodes/common/macro_parser/resolution.py +168 -0
- griptape_nodes/common/macro_parser/segments.py +42 -0
- griptape_nodes/exe_types/core_types.py +241 -4
- griptape_nodes/exe_types/node_types.py +7 -1
- griptape_nodes/exe_types/param_components/huggingface/__init__.py +1 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +168 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +38 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_parameter.py +33 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_utils.py +136 -0
- griptape_nodes/exe_types/param_components/log_parameter.py +136 -0
- griptape_nodes/exe_types/param_components/seed_parameter.py +59 -0
- griptape_nodes/exe_types/param_types/__init__.py +1 -0
- griptape_nodes/exe_types/param_types/parameter_bool.py +221 -0
- griptape_nodes/exe_types/param_types/parameter_float.py +179 -0
- griptape_nodes/exe_types/param_types/parameter_int.py +183 -0
- griptape_nodes/exe_types/param_types/parameter_number.py +380 -0
- griptape_nodes/exe_types/param_types/parameter_string.py +232 -0
- griptape_nodes/node_library/library_registry.py +2 -1
- griptape_nodes/retained_mode/events/app_events.py +21 -0
- griptape_nodes/retained_mode/events/os_events.py +142 -6
- griptape_nodes/retained_mode/events/parameter_events.py +2 -0
- griptape_nodes/retained_mode/griptape_nodes.py +14 -0
- griptape_nodes/retained_mode/managers/agent_manager.py +5 -3
- griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +19 -1
- griptape_nodes/retained_mode/managers/library_manager.py +27 -32
- griptape_nodes/retained_mode/managers/node_manager.py +14 -1
- griptape_nodes/retained_mode/managers/os_manager.py +403 -124
- griptape_nodes/retained_mode/managers/user_manager.py +120 -0
- griptape_nodes/retained_mode/managers/workflow_manager.py +44 -34
- griptape_nodes/traits/multi_options.py +26 -2
- griptape_nodes/utils/huggingface_utils.py +136 -0
- {griptape_nodes-0.59.2.dist-info → griptape_nodes-0.60.0.dist-info}/METADATA +1 -1
- {griptape_nodes-0.59.2.dist-info → griptape_nodes-0.60.0.dist-info}/RECORD +41 -18
- {griptape_nodes-0.59.2.dist-info → griptape_nodes-0.60.0.dist-info}/WHEEL +1 -1
- {griptape_nodes-0.59.2.dist-info → griptape_nodes-0.60.0.dist-info}/entry_points.txt +0 -0
|
@@ -7,7 +7,7 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, NamedTuple
|
|
11
11
|
|
|
12
12
|
from binaryornot.check import is_binary
|
|
13
13
|
from rich.console import Console
|
|
@@ -18,6 +18,8 @@ from griptape_nodes.retained_mode.events.os_events import (
|
|
|
18
18
|
CreateFileRequest,
|
|
19
19
|
CreateFileResultFailure,
|
|
20
20
|
CreateFileResultSuccess,
|
|
21
|
+
ExistingFilePolicy,
|
|
22
|
+
FileIOFailureReason,
|
|
21
23
|
FileSystemEntry,
|
|
22
24
|
ListDirectoryRequest,
|
|
23
25
|
ListDirectoryResultFailure,
|
|
@@ -31,6 +33,9 @@ from griptape_nodes.retained_mode.events.os_events import (
|
|
|
31
33
|
RenameFileRequest,
|
|
32
34
|
RenameFileResultFailure,
|
|
33
35
|
RenameFileResultSuccess,
|
|
36
|
+
WriteFileRequest,
|
|
37
|
+
WriteFileResultFailure,
|
|
38
|
+
WriteFileResultSuccess,
|
|
34
39
|
)
|
|
35
40
|
from griptape_nodes.retained_mode.events.resource_events import (
|
|
36
41
|
CreateResourceInstanceRequest,
|
|
@@ -45,6 +50,9 @@ from griptape_nodes.retained_mode.managers.resource_types.os_resource import OSR
|
|
|
45
50
|
|
|
46
51
|
console = Console()
|
|
47
52
|
|
|
53
|
+
# Windows MAX_PATH limit - paths longer than this need \\?\ prefix
|
|
54
|
+
WINDOWS_MAX_PATH = 260
|
|
55
|
+
|
|
48
56
|
|
|
49
57
|
@dataclass
|
|
50
58
|
class DiskSpaceInfo:
|
|
@@ -55,6 +63,16 @@ class DiskSpaceInfo:
|
|
|
55
63
|
free: int
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
class FileContentResult(NamedTuple):
|
|
67
|
+
"""Result from reading file content."""
|
|
68
|
+
|
|
69
|
+
content: str | bytes
|
|
70
|
+
encoding: str | None
|
|
71
|
+
mime_type: str
|
|
72
|
+
compression_encoding: str | None
|
|
73
|
+
file_size: int
|
|
74
|
+
|
|
75
|
+
|
|
58
76
|
class OSManager:
|
|
59
77
|
"""A class to manage OS-level scenarios.
|
|
60
78
|
|
|
@@ -83,6 +101,10 @@ class OSManager:
|
|
|
83
101
|
request_type=RenameFileRequest, callback=self.on_rename_file_request
|
|
84
102
|
)
|
|
85
103
|
|
|
104
|
+
event_manager.assign_manager_to_request_type(
|
|
105
|
+
request_type=WriteFileRequest, callback=self.on_write_file_request
|
|
106
|
+
)
|
|
107
|
+
|
|
86
108
|
# Register for app initialization event to setup system resources
|
|
87
109
|
event_manager.add_listener_to_app_event(AppInitializationComplete, self.on_app_initialization_complete)
|
|
88
110
|
|
|
@@ -156,6 +178,31 @@ class OSManager:
|
|
|
156
178
|
logger.debug(msg)
|
|
157
179
|
return True, relative
|
|
158
180
|
|
|
181
|
+
def _normalize_path_for_platform(self, path: Path) -> str:
|
|
182
|
+
r"""Convert Path to string with Windows long path support if needed.
|
|
183
|
+
|
|
184
|
+
Windows has a 260 character path limit (MAX_PATH). Paths longer than this
|
|
185
|
+
need the \\?\ prefix to work correctly. This method transparently adds
|
|
186
|
+
the prefix when needed on Windows.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
path: Path object to convert to string
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
String representation of path, with Windows long path prefix if needed
|
|
193
|
+
"""
|
|
194
|
+
path_str = str(path.resolve())
|
|
195
|
+
|
|
196
|
+
# Windows long path handling (paths > WINDOWS_MAX_PATH chars need \\?\ prefix)
|
|
197
|
+
if self.is_windows() and len(path_str) > WINDOWS_MAX_PATH and not path_str.startswith("\\\\?\\"):
|
|
198
|
+
# UNC paths (\\server\share) need \\?\UNC\ prefix
|
|
199
|
+
if path_str.startswith("\\\\"):
|
|
200
|
+
return f"\\\\?\\UNC\\{path_str[2:]}"
|
|
201
|
+
# Regular paths need \\?\ prefix
|
|
202
|
+
return f"\\\\?\\{path_str}"
|
|
203
|
+
|
|
204
|
+
return path_str
|
|
205
|
+
|
|
159
206
|
def _validate_read_file_request(self, request: ReadFileRequest) -> tuple[Path, str]:
|
|
160
207
|
"""Validate read file request and return resolved file path and path string."""
|
|
161
208
|
# Validate that exactly one of file_path or file_entry is provided
|
|
@@ -236,12 +283,12 @@ class OSManager:
|
|
|
236
283
|
if request.path_to_file is None and request.file_entry is None:
|
|
237
284
|
msg = "Either path_to_file or file_entry must be provided"
|
|
238
285
|
logger.error(msg)
|
|
239
|
-
return OpenAssociatedFileResultFailure(result_details=msg)
|
|
286
|
+
return OpenAssociatedFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
240
287
|
|
|
241
288
|
if request.path_to_file is not None and request.file_entry is not None:
|
|
242
289
|
msg = "Only one of path_to_file or file_entry should be provided, not both"
|
|
243
290
|
logger.error(msg)
|
|
244
|
-
return OpenAssociatedFileResultFailure(result_details=msg)
|
|
291
|
+
return OpenAssociatedFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
245
292
|
|
|
246
293
|
# Get the file path to open
|
|
247
294
|
if request.file_entry is not None:
|
|
@@ -254,13 +301,13 @@ class OSManager:
|
|
|
254
301
|
# This should never happen due to validation above, but type checker needs it
|
|
255
302
|
msg = "No valid file path provided"
|
|
256
303
|
logger.error(msg)
|
|
257
|
-
return OpenAssociatedFileResultFailure(result_details=msg)
|
|
304
|
+
return OpenAssociatedFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
258
305
|
|
|
259
306
|
# At this point, file_path_str is guaranteed to be a string
|
|
260
307
|
if file_path_str is None:
|
|
261
308
|
msg = "No valid file path provided"
|
|
262
309
|
logger.error(msg)
|
|
263
|
-
return OpenAssociatedFileResultFailure(result_details=msg)
|
|
310
|
+
return OpenAssociatedFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
264
311
|
|
|
265
312
|
# Sanitize and validate the path (file or directory)
|
|
266
313
|
try:
|
|
@@ -269,12 +316,16 @@ class OSManager:
|
|
|
269
316
|
except (ValueError, RuntimeError):
|
|
270
317
|
details = f"Invalid file path: '{file_path_str}'"
|
|
271
318
|
logger.info(details)
|
|
272
|
-
return OpenAssociatedFileResultFailure(
|
|
319
|
+
return OpenAssociatedFileResultFailure(
|
|
320
|
+
failure_reason=FileIOFailureReason.INVALID_PATH, result_details=details
|
|
321
|
+
)
|
|
273
322
|
|
|
274
323
|
if not path.exists():
|
|
275
324
|
details = f"Path does not exist: '{path}'"
|
|
276
325
|
logger.info(details)
|
|
277
|
-
return OpenAssociatedFileResultFailure(
|
|
326
|
+
return OpenAssociatedFileResultFailure(
|
|
327
|
+
failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=details
|
|
328
|
+
)
|
|
278
329
|
|
|
279
330
|
logger.info("Attempting to open path: %s on platform: %s", path, sys.platform)
|
|
280
331
|
|
|
@@ -283,12 +334,12 @@ class OSManager:
|
|
|
283
334
|
if self.is_windows():
|
|
284
335
|
# Linter complains but this is the recommended way on Windows
|
|
285
336
|
# We can ignore this warning as we've validated the path
|
|
286
|
-
os.startfile(
|
|
337
|
+
os.startfile(self._normalize_path_for_platform(path)) # noqa: S606 # pyright: ignore[reportAttributeAccessIssue]
|
|
287
338
|
logger.info("Opened path on Windows: %s", path)
|
|
288
339
|
elif self.is_mac():
|
|
289
340
|
# On macOS, open should be in a standard location
|
|
290
341
|
subprocess.run( # noqa: S603
|
|
291
|
-
["/usr/bin/open",
|
|
342
|
+
["/usr/bin/open", self._normalize_path_for_platform(path)],
|
|
292
343
|
check=True, # Explicitly use check
|
|
293
344
|
capture_output=True,
|
|
294
345
|
text=True,
|
|
@@ -303,10 +354,12 @@ class OSManager:
|
|
|
303
354
|
if not xdg_path:
|
|
304
355
|
details = "xdg-open not found in standard locations"
|
|
305
356
|
logger.info(details)
|
|
306
|
-
return OpenAssociatedFileResultFailure(
|
|
357
|
+
return OpenAssociatedFileResultFailure(
|
|
358
|
+
failure_reason=FileIOFailureReason.IO_ERROR, result_details=details
|
|
359
|
+
)
|
|
307
360
|
|
|
308
361
|
subprocess.run( # noqa: S603
|
|
309
|
-
[xdg_path,
|
|
362
|
+
[xdg_path, self._normalize_path_for_platform(path)],
|
|
310
363
|
check=True, # Explicitly use check
|
|
311
364
|
capture_output=True,
|
|
312
365
|
text=True,
|
|
@@ -315,7 +368,9 @@ class OSManager:
|
|
|
315
368
|
else:
|
|
316
369
|
details = f"Unsupported platform: '{platform_name}'"
|
|
317
370
|
logger.info(details)
|
|
318
|
-
return OpenAssociatedFileResultFailure(
|
|
371
|
+
return OpenAssociatedFileResultFailure(
|
|
372
|
+
failure_reason=FileIOFailureReason.IO_ERROR, result_details=details
|
|
373
|
+
)
|
|
319
374
|
|
|
320
375
|
return OpenAssociatedFileResultSuccess(result_details="File opened successfully in associated application.")
|
|
321
376
|
except subprocess.CalledProcessError as e:
|
|
@@ -323,11 +378,11 @@ class OSManager:
|
|
|
323
378
|
f"Process error when opening file: return code={e.returncode}, stdout={e.stdout}, stderr={e.stderr}"
|
|
324
379
|
)
|
|
325
380
|
logger.error(details)
|
|
326
|
-
return OpenAssociatedFileResultFailure(result_details=details)
|
|
381
|
+
return OpenAssociatedFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=details)
|
|
327
382
|
except Exception as e:
|
|
328
383
|
details = f"Exception occurred when trying to open path: {e}"
|
|
329
384
|
logger.error(details)
|
|
330
|
-
return OpenAssociatedFileResultFailure(result_details=details)
|
|
385
|
+
return OpenAssociatedFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=details)
|
|
331
386
|
|
|
332
387
|
def _detect_mime_type(self, file_path: Path) -> str | None:
|
|
333
388
|
"""Detect MIME type for a file. Returns None for directories or if detection fails."""
|
|
@@ -335,7 +390,7 @@ class OSManager:
|
|
|
335
390
|
return None
|
|
336
391
|
|
|
337
392
|
try:
|
|
338
|
-
mime_type, _ = mimetypes.guess_type(
|
|
393
|
+
mime_type, _ = mimetypes.guess_type(self._normalize_path_for_platform(file_path), strict=True)
|
|
339
394
|
if mime_type is None:
|
|
340
395
|
mime_type = "text/plain"
|
|
341
396
|
return mime_type # noqa: TRY300
|
|
@@ -344,7 +399,7 @@ class OSManager:
|
|
|
344
399
|
logger.warning(msg)
|
|
345
400
|
return "text/plain"
|
|
346
401
|
|
|
347
|
-
def on_list_directory_request(self, request: ListDirectoryRequest) -> ResultPayload: # noqa: C901, PLR0911
|
|
402
|
+
def on_list_directory_request(self, request: ListDirectoryRequest) -> ResultPayload: # noqa: C901, PLR0911, PLR0912
|
|
348
403
|
"""Handle a request to list directory contents."""
|
|
349
404
|
try:
|
|
350
405
|
# Get the directory path to list
|
|
@@ -362,18 +417,18 @@ class OSManager:
|
|
|
362
417
|
if not directory.exists():
|
|
363
418
|
msg = f"Directory does not exist: {directory}"
|
|
364
419
|
logger.error(msg)
|
|
365
|
-
return ListDirectoryResultFailure(result_details=msg)
|
|
420
|
+
return ListDirectoryResultFailure(failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=msg)
|
|
366
421
|
if not directory.is_dir():
|
|
367
|
-
msg = f"
|
|
422
|
+
msg = f"Path is not a directory: {directory}"
|
|
368
423
|
logger.error(msg)
|
|
369
|
-
return ListDirectoryResultFailure(result_details=msg)
|
|
424
|
+
return ListDirectoryResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
370
425
|
|
|
371
426
|
# Check workspace constraints
|
|
372
427
|
is_workspace_path, relative_or_abs_path = self._validate_workspace_path(directory)
|
|
373
428
|
if request.workspace_only and not is_workspace_path:
|
|
374
429
|
msg = f"Directory is outside workspace: {directory}"
|
|
375
430
|
logger.error(msg)
|
|
376
|
-
return ListDirectoryResultFailure(result_details=msg)
|
|
431
|
+
return ListDirectoryResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
377
432
|
|
|
378
433
|
entries = []
|
|
379
434
|
try:
|
|
@@ -403,10 +458,16 @@ class OSManager:
|
|
|
403
458
|
logger.warning(msg)
|
|
404
459
|
continue
|
|
405
460
|
|
|
406
|
-
except
|
|
407
|
-
msg = f"
|
|
461
|
+
except PermissionError as e:
|
|
462
|
+
msg = f"Permission denied listing directory {directory}: {e}"
|
|
463
|
+
logger.error(msg)
|
|
464
|
+
return ListDirectoryResultFailure(
|
|
465
|
+
failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg
|
|
466
|
+
)
|
|
467
|
+
except OSError as e:
|
|
468
|
+
msg = f"I/O error listing directory {directory}: {e}"
|
|
408
469
|
logger.error(msg)
|
|
409
|
-
return ListDirectoryResultFailure(result_details=msg)
|
|
470
|
+
return ListDirectoryResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
410
471
|
|
|
411
472
|
# Return appropriate path format based on mode
|
|
412
473
|
if request.workspace_only:
|
|
@@ -428,62 +489,78 @@ class OSManager:
|
|
|
428
489
|
except Exception as e:
|
|
429
490
|
msg = f"Unexpected error in list_directory: {type(e).__name__}: {e}"
|
|
430
491
|
logger.error(msg)
|
|
431
|
-
return ListDirectoryResultFailure(result_details=msg)
|
|
492
|
+
return ListDirectoryResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
|
|
432
493
|
|
|
433
|
-
def on_read_file_request(self, request: ReadFileRequest) -> ResultPayload:
|
|
494
|
+
def on_read_file_request(self, request: ReadFileRequest) -> ResultPayload: # noqa: PLR0911
|
|
434
495
|
"""Handle a request to read file contents with automatic text/binary detection."""
|
|
435
|
-
#
|
|
436
|
-
file_path: Path | None = None
|
|
437
|
-
file_path_str: str | None = None
|
|
438
|
-
|
|
496
|
+
# Validate request and get file path
|
|
439
497
|
try:
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
498
|
+
file_path, _file_path_str = self._validate_read_file_request(request)
|
|
499
|
+
except FileNotFoundError as e:
|
|
500
|
+
msg = f"File not found: {e}"
|
|
501
|
+
logger.error(msg)
|
|
502
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=msg)
|
|
503
|
+
except PermissionError as e:
|
|
504
|
+
msg = f"Permission denied: {e}"
|
|
505
|
+
logger.error(msg)
|
|
506
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
507
|
+
except (ValueError, RuntimeError) as e:
|
|
508
|
+
msg = f"Invalid path: {e}"
|
|
509
|
+
logger.error(msg)
|
|
510
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
511
|
+
except OSError as e:
|
|
512
|
+
msg = f"I/O error validating path: {e}"
|
|
513
|
+
logger.error(msg)
|
|
514
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
454
515
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
516
|
+
# Read file content
|
|
517
|
+
try:
|
|
518
|
+
result = self._read_file_content(file_path, request)
|
|
519
|
+
except PermissionError as e:
|
|
520
|
+
msg = f"Permission denied for file {file_path}: {e}"
|
|
521
|
+
logger.error(msg)
|
|
522
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
523
|
+
except IsADirectoryError:
|
|
524
|
+
msg = f"Path is a directory, not a file: {file_path}"
|
|
525
|
+
logger.error(msg)
|
|
526
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.IS_DIRECTORY, result_details=msg)
|
|
527
|
+
except UnicodeDecodeError as e:
|
|
528
|
+
msg = f"Encoding error for file {file_path}: {e}"
|
|
529
|
+
logger.error(msg)
|
|
530
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.ENCODING_ERROR, result_details=msg)
|
|
531
|
+
except OSError as e:
|
|
532
|
+
msg = f"I/O error for file {file_path}: {e}"
|
|
458
533
|
logger.error(msg)
|
|
459
|
-
return ReadFileResultFailure(result_details=msg)
|
|
534
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
460
535
|
except Exception as e:
|
|
461
|
-
|
|
462
|
-
path_info = ""
|
|
463
|
-
if file_path is not None:
|
|
464
|
-
path_info = f" for {file_path}"
|
|
465
|
-
elif file_path_str is not None:
|
|
466
|
-
path_info = f" for {file_path_str}"
|
|
467
|
-
|
|
468
|
-
msg = f"Unexpected error in read_file{path_info}: {type(e).__name__}: {e}"
|
|
536
|
+
msg = f"Unexpected error reading file {file_path}: {type(e).__name__}: {e}"
|
|
469
537
|
logger.error(msg)
|
|
470
|
-
return ReadFileResultFailure(result_details=msg)
|
|
538
|
+
return ReadFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
|
|
539
|
+
|
|
540
|
+
# SUCCESS PATH - Only reached if no exceptions occurred
|
|
541
|
+
return ReadFileResultSuccess(
|
|
542
|
+
content=result.content,
|
|
543
|
+
file_size=result.file_size,
|
|
544
|
+
mime_type=result.mime_type,
|
|
545
|
+
encoding=result.encoding,
|
|
546
|
+
compression_encoding=result.compression_encoding,
|
|
547
|
+
result_details="File read successfully.",
|
|
548
|
+
)
|
|
471
549
|
|
|
472
|
-
def _read_file_content(
|
|
473
|
-
|
|
474
|
-
) -> tuple[bytes | str, str | None, str, str | None, int]:
|
|
475
|
-
"""Read file content and return content, encoding, mime_type, compression_encoding, and file_size."""
|
|
550
|
+
def _read_file_content(self, file_path: Path, request: ReadFileRequest) -> FileContentResult:
|
|
551
|
+
"""Read file content and return FileContentResult with all file information."""
|
|
476
552
|
# Get file size
|
|
477
553
|
file_size = file_path.stat().st_size
|
|
478
554
|
|
|
479
555
|
# Determine MIME type and compression encoding
|
|
480
|
-
|
|
556
|
+
normalized_path = self._normalize_path_for_platform(file_path)
|
|
557
|
+
mime_type, compression_encoding = mimetypes.guess_type(normalized_path, strict=True)
|
|
481
558
|
if mime_type is None:
|
|
482
559
|
mime_type = "text/plain"
|
|
483
560
|
|
|
484
561
|
# Determine if file is binary
|
|
485
562
|
try:
|
|
486
|
-
is_binary_file = is_binary(
|
|
563
|
+
is_binary_file = is_binary(normalized_path)
|
|
487
564
|
except Exception as e:
|
|
488
565
|
msg = f"binaryornot detection failed for {file_path}: {e}"
|
|
489
566
|
logger.warning(msg)
|
|
@@ -497,7 +574,13 @@ class OSManager:
|
|
|
497
574
|
else:
|
|
498
575
|
content, encoding = self._read_binary_file(file_path, mime_type)
|
|
499
576
|
|
|
500
|
-
return
|
|
577
|
+
return FileContentResult(
|
|
578
|
+
content=content,
|
|
579
|
+
encoding=encoding,
|
|
580
|
+
mime_type=mime_type,
|
|
581
|
+
compression_encoding=compression_encoding,
|
|
582
|
+
file_size=file_size,
|
|
583
|
+
)
|
|
501
584
|
|
|
502
585
|
def _read_text_file(self, file_path: Path, requested_encoding: str) -> tuple[bytes | str, str | None]:
|
|
503
586
|
"""Read file as text with fallback encodings."""
|
|
@@ -560,6 +643,148 @@ class OSManager:
|
|
|
560
643
|
else:
|
|
561
644
|
return static_url
|
|
562
645
|
|
|
646
|
+
def on_write_file_request(self, request: WriteFileRequest) -> ResultPayload: # noqa: PLR0911, PLR0912, PLR0915, C901
|
|
647
|
+
"""Handle a request to write content to a file."""
|
|
648
|
+
# Check for CREATE_NEW policy - not yet implemented
|
|
649
|
+
if request.existing_file_policy == ExistingFilePolicy.CREATE_NEW:
|
|
650
|
+
msg = "CREATE_NEW policy not yet implemented"
|
|
651
|
+
logger.error(msg)
|
|
652
|
+
return WriteFileResultFailure(
|
|
653
|
+
failure_reason=FileIOFailureReason.IO_ERROR,
|
|
654
|
+
result_details=msg,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
# Resolve file path
|
|
658
|
+
try:
|
|
659
|
+
file_path = self._resolve_file_path(request.file_path, workspace_only=False)
|
|
660
|
+
except (ValueError, RuntimeError) as e:
|
|
661
|
+
msg = f"Invalid path: {e}"
|
|
662
|
+
logger.error(msg)
|
|
663
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
664
|
+
|
|
665
|
+
# Get normalized path for file operations (handles Windows long paths)
|
|
666
|
+
normalized_path = self._normalize_path_for_platform(file_path)
|
|
667
|
+
|
|
668
|
+
# Check if path is a directory (must check before attempting to write)
|
|
669
|
+
try:
|
|
670
|
+
if Path(normalized_path).is_dir():
|
|
671
|
+
msg = f"Path is a directory, not a file: {file_path}"
|
|
672
|
+
logger.error(msg)
|
|
673
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IS_DIRECTORY, result_details=msg)
|
|
674
|
+
except OSError as e:
|
|
675
|
+
msg = f"Error checking if path is directory {file_path}: {e}"
|
|
676
|
+
logger.error(msg)
|
|
677
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
678
|
+
|
|
679
|
+
# Check existing file policy (only if not appending)
|
|
680
|
+
if not request.append and request.existing_file_policy == ExistingFilePolicy.FAIL:
|
|
681
|
+
try:
|
|
682
|
+
# Use os.path.exists with normalized path to handle Windows long paths
|
|
683
|
+
if os.path.exists(normalized_path): # noqa: PTH110
|
|
684
|
+
msg = f"File exists and existing_file_policy is FAIL: {file_path}"
|
|
685
|
+
logger.error(msg)
|
|
686
|
+
return WriteFileResultFailure(
|
|
687
|
+
failure_reason=FileIOFailureReason.POLICY_NO_OVERWRITE,
|
|
688
|
+
result_details=msg,
|
|
689
|
+
)
|
|
690
|
+
except OSError as e:
|
|
691
|
+
msg = f"Error checking if file exists {file_path}: {e}"
|
|
692
|
+
logger.error(msg)
|
|
693
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
694
|
+
|
|
695
|
+
# Check and create parent directory if needed
|
|
696
|
+
parent_normalized = self._normalize_path_for_platform(file_path.parent)
|
|
697
|
+
try:
|
|
698
|
+
if not os.path.exists(parent_normalized): # noqa: PTH110
|
|
699
|
+
if not request.create_parents:
|
|
700
|
+
msg = f"Parent directory does not exist and create_parents is False: {file_path.parent}"
|
|
701
|
+
logger.error(msg)
|
|
702
|
+
return WriteFileResultFailure(
|
|
703
|
+
failure_reason=FileIOFailureReason.POLICY_NO_CREATE_PARENT_DIRS,
|
|
704
|
+
result_details=msg,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
# Create parent directories using os.makedirs to handle Windows long paths
|
|
708
|
+
os.makedirs(parent_normalized, exist_ok=True) # noqa: PTH103
|
|
709
|
+
except PermissionError as e:
|
|
710
|
+
msg = f"Permission denied creating parent directory {file_path.parent}: {e}"
|
|
711
|
+
logger.error(msg)
|
|
712
|
+
return WriteFileResultFailure(
|
|
713
|
+
failure_reason=FileIOFailureReason.PERMISSION_DENIED,
|
|
714
|
+
result_details=msg,
|
|
715
|
+
)
|
|
716
|
+
except OSError as e:
|
|
717
|
+
msg = f"Error creating parent directory {file_path.parent}: {e}"
|
|
718
|
+
logger.error(msg)
|
|
719
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
720
|
+
|
|
721
|
+
# Write file content
|
|
722
|
+
try:
|
|
723
|
+
bytes_written = self._write_file_content(
|
|
724
|
+
normalized_path, request.content, request.encoding, append=request.append
|
|
725
|
+
)
|
|
726
|
+
except PermissionError as e:
|
|
727
|
+
msg = f"Permission denied writing to file {file_path}: {e}"
|
|
728
|
+
logger.error(msg)
|
|
729
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
730
|
+
except IsADirectoryError:
|
|
731
|
+
msg = f"Path is a directory, not a file: {file_path}"
|
|
732
|
+
logger.error(msg)
|
|
733
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IS_DIRECTORY, result_details=msg)
|
|
734
|
+
except UnicodeEncodeError as e:
|
|
735
|
+
msg = f"Encoding error writing to file {file_path}: {e}"
|
|
736
|
+
logger.error(msg)
|
|
737
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.ENCODING_ERROR, result_details=msg)
|
|
738
|
+
except OSError as e:
|
|
739
|
+
# Check for disk full
|
|
740
|
+
if "No space left" in str(e) or "Disk full" in str(e):
|
|
741
|
+
msg = f"Disk full writing to file {file_path}: {e}"
|
|
742
|
+
logger.error(msg)
|
|
743
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg)
|
|
744
|
+
|
|
745
|
+
msg = f"I/O error writing to file {file_path}: {e}"
|
|
746
|
+
logger.error(msg)
|
|
747
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
748
|
+
except Exception as e:
|
|
749
|
+
msg = f"Unexpected error writing to file {file_path}: {type(e).__name__}: {e}"
|
|
750
|
+
logger.error(msg)
|
|
751
|
+
return WriteFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
|
|
752
|
+
|
|
753
|
+
# SUCCESS PATH - Only reached if no exceptions occurred
|
|
754
|
+
return WriteFileResultSuccess(
|
|
755
|
+
final_file_path=str(file_path),
|
|
756
|
+
bytes_written=bytes_written,
|
|
757
|
+
result_details=f"File written successfully: {file_path}",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def _write_file_content(self, normalized_path: str, content: str | bytes, encoding: str, *, append: bool) -> int:
|
|
761
|
+
"""Write content to a file and return bytes written.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
normalized_path: Normalized path string (with Windows long path prefix if needed)
|
|
765
|
+
content: Content to write (str for text, bytes for binary)
|
|
766
|
+
encoding: Text encoding (ignored for bytes)
|
|
767
|
+
append: If True, append to file; if False, overwrite
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
Number of bytes written
|
|
771
|
+
"""
|
|
772
|
+
# Determine mode based on content type and append flag
|
|
773
|
+
if isinstance(content, bytes):
|
|
774
|
+
mode = "ab" if append else "wb"
|
|
775
|
+
# Use open() instead of Path.open() to support Windows long paths with \\?\ prefix
|
|
776
|
+
with open(normalized_path, mode) as f: # noqa: PTH123
|
|
777
|
+
f.write(content)
|
|
778
|
+
return len(content)
|
|
779
|
+
|
|
780
|
+
# Text content
|
|
781
|
+
mode = "a" if append else "w"
|
|
782
|
+
# Use open() instead of Path.open() to support Windows long paths with \\?\ prefix
|
|
783
|
+
with open(normalized_path, mode, encoding=encoding) as f: # noqa: PTH123
|
|
784
|
+
f.write(content)
|
|
785
|
+
# Return byte count for text (encoded size)
|
|
786
|
+
return len(content.encode(encoding))
|
|
787
|
+
|
|
563
788
|
@staticmethod
|
|
564
789
|
def get_disk_space_info(path: Path) -> DiskSpaceInfo:
|
|
565
790
|
"""Get disk space information for a given path.
|
|
@@ -754,37 +979,52 @@ class OSManager:
|
|
|
754
979
|
|
|
755
980
|
return removed_count > 0
|
|
756
981
|
|
|
757
|
-
def on_create_file_request(self, request: CreateFileRequest) -> ResultPayload:
|
|
982
|
+
def on_create_file_request(self, request: CreateFileRequest) -> ResultPayload: # noqa: PLR0911, PLR0912, C901
|
|
758
983
|
"""Handle a request to create a file or directory."""
|
|
984
|
+
# Get the full path
|
|
759
985
|
try:
|
|
760
|
-
# Get the full path using the new method
|
|
761
986
|
full_path_str = request.get_full_path()
|
|
987
|
+
except ValueError as e:
|
|
988
|
+
msg = f"Invalid path specification: {e}"
|
|
989
|
+
logger.error(msg)
|
|
990
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
762
991
|
|
|
763
|
-
|
|
764
|
-
|
|
992
|
+
# Determine if path is absolute (not constrained to workspace)
|
|
993
|
+
is_absolute = Path(full_path_str).is_absolute()
|
|
765
994
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
995
|
+
# If workspace_only is True and path is absolute, it's outside workspace
|
|
996
|
+
if request.workspace_only and is_absolute:
|
|
997
|
+
msg = f"Absolute path is outside workspace: {full_path_str}"
|
|
998
|
+
logger.error(msg)
|
|
999
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
771
1000
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1001
|
+
# Resolve path - if absolute, use as-is; if relative, align to workspace
|
|
1002
|
+
if is_absolute:
|
|
1003
|
+
file_path = Path(full_path_str).resolve()
|
|
1004
|
+
else:
|
|
1005
|
+
file_path = (self._get_workspace_path() / full_path_str).resolve()
|
|
777
1006
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1007
|
+
# Check if it already exists - warn but treat as success
|
|
1008
|
+
if file_path.exists():
|
|
1009
|
+
msg = f"Path already exists: {file_path}"
|
|
1010
|
+
return CreateFileResultSuccess(
|
|
1011
|
+
created_path=str(file_path), result_details=ResultDetails(message=msg, level=logging.WARNING)
|
|
1012
|
+
)
|
|
784
1013
|
|
|
785
|
-
|
|
1014
|
+
# Create parent directories if needed
|
|
1015
|
+
try:
|
|
786
1016
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1017
|
+
except PermissionError as e:
|
|
1018
|
+
msg = f"Permission denied creating parent directory for {file_path}: {e}"
|
|
1019
|
+
logger.error(msg)
|
|
1020
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
1021
|
+
except OSError as e:
|
|
1022
|
+
msg = f"I/O error creating parent directory for {file_path}: {e}"
|
|
1023
|
+
logger.error(msg)
|
|
1024
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
787
1025
|
|
|
1026
|
+
# Create file or directory
|
|
1027
|
+
try:
|
|
788
1028
|
if request.is_directory:
|
|
789
1029
|
file_path.mkdir()
|
|
790
1030
|
logger.info("Created directory: %s", file_path)
|
|
@@ -796,65 +1036,104 @@ class OSManager:
|
|
|
796
1036
|
else:
|
|
797
1037
|
file_path.touch()
|
|
798
1038
|
logger.info("Created empty file: %s", file_path)
|
|
1039
|
+
except PermissionError as e:
|
|
1040
|
+
msg = f"Permission denied creating {file_path}: {e}"
|
|
1041
|
+
logger.error(msg)
|
|
1042
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
1043
|
+
except OSError as e:
|
|
1044
|
+
# Check for disk full
|
|
1045
|
+
if "No space left" in str(e) or "Disk full" in str(e):
|
|
1046
|
+
msg = f"Disk full creating {file_path}: {e}"
|
|
1047
|
+
logger.error(msg)
|
|
1048
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.DISK_FULL, result_details=msg)
|
|
799
1049
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
)
|
|
804
|
-
|
|
1050
|
+
msg = f"I/O error creating {file_path}: {e}"
|
|
1051
|
+
logger.error(msg)
|
|
1052
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
805
1053
|
except Exception as e:
|
|
806
|
-
|
|
807
|
-
msg = f"Failed to create {'directory' if request.is_directory else 'file'} at {path_info}: {e}"
|
|
1054
|
+
msg = f"Unexpected error creating {file_path}: {type(e).__name__}: {e}"
|
|
808
1055
|
logger.error(msg)
|
|
809
|
-
return CreateFileResultFailure(result_details=msg)
|
|
1056
|
+
return CreateFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
|
|
810
1057
|
|
|
811
|
-
|
|
1058
|
+
# SUCCESS PATH
|
|
1059
|
+
return CreateFileResultSuccess(
|
|
1060
|
+
created_path=str(file_path),
|
|
1061
|
+
result_details=f"{'Directory' if request.is_directory else 'File'} created successfully at {file_path}",
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
def on_rename_file_request(self, request: RenameFileRequest) -> ResultPayload: # noqa: PLR0911, C901
|
|
812
1065
|
"""Handle a request to rename a file or directory."""
|
|
1066
|
+
# Resolve and validate paths
|
|
813
1067
|
try:
|
|
814
|
-
# Resolve and validate old path
|
|
815
1068
|
old_path = self._resolve_file_path(request.old_path, workspace_only=request.workspace_only is True)
|
|
1069
|
+
except (ValueError, RuntimeError) as e:
|
|
1070
|
+
msg = f"Invalid source path: {e}"
|
|
1071
|
+
logger.error(msg)
|
|
1072
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
816
1073
|
|
|
817
|
-
|
|
1074
|
+
try:
|
|
818
1075
|
new_path = self._resolve_file_path(request.new_path, workspace_only=request.workspace_only is True)
|
|
1076
|
+
except (ValueError, RuntimeError) as e:
|
|
1077
|
+
msg = f"Invalid destination path: {e}"
|
|
1078
|
+
logger.error(msg)
|
|
1079
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
819
1080
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1081
|
+
# Check if old path exists
|
|
1082
|
+
if not old_path.exists():
|
|
1083
|
+
msg = f"Source path does not exist: {old_path}"
|
|
1084
|
+
logger.error(msg)
|
|
1085
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.FILE_NOT_FOUND, result_details=msg)
|
|
825
1086
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1087
|
+
# Check if new path already exists
|
|
1088
|
+
if new_path.exists():
|
|
1089
|
+
msg = f"Destination path already exists: {new_path}"
|
|
1090
|
+
logger.error(msg)
|
|
1091
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
831
1092
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1093
|
+
# Check workspace constraints for both paths
|
|
1094
|
+
is_old_in_workspace, _ = self._validate_workspace_path(old_path)
|
|
1095
|
+
is_new_in_workspace, _ = self._validate_workspace_path(new_path)
|
|
835
1096
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1097
|
+
if request.workspace_only and (not is_old_in_workspace or not is_new_in_workspace):
|
|
1098
|
+
msg = f"One or both paths are outside workspace: {old_path} -> {new_path}"
|
|
1099
|
+
logger.error(msg)
|
|
1100
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.INVALID_PATH, result_details=msg)
|
|
840
1101
|
|
|
841
|
-
|
|
1102
|
+
# Create parent directories for new path if needed
|
|
1103
|
+
try:
|
|
842
1104
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1105
|
+
except PermissionError as e:
|
|
1106
|
+
msg = f"Permission denied creating parent directory for {new_path}: {e}"
|
|
1107
|
+
logger.error(msg)
|
|
1108
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
1109
|
+
except OSError as e:
|
|
1110
|
+
msg = f"I/O error creating parent directory for {new_path}: {e}"
|
|
1111
|
+
logger.error(msg)
|
|
1112
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
843
1113
|
|
|
844
|
-
|
|
1114
|
+
# Perform the rename operation
|
|
1115
|
+
try:
|
|
845
1116
|
old_path.rename(new_path)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
)
|
|
853
|
-
|
|
1117
|
+
except PermissionError as e:
|
|
1118
|
+
msg = f"Permission denied renaming {old_path} to {new_path}: {e}"
|
|
1119
|
+
logger.error(msg)
|
|
1120
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.PERMISSION_DENIED, result_details=msg)
|
|
1121
|
+
except OSError as e:
|
|
1122
|
+
msg = f"I/O error renaming {old_path} to {new_path}: {e}"
|
|
1123
|
+
logger.error(msg)
|
|
1124
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.IO_ERROR, result_details=msg)
|
|
854
1125
|
except Exception as e:
|
|
855
|
-
msg = f"
|
|
1126
|
+
msg = f"Unexpected error renaming {old_path} to {new_path}: {type(e).__name__}: {e}"
|
|
856
1127
|
logger.error(msg)
|
|
857
|
-
return RenameFileResultFailure(result_details=msg)
|
|
1128
|
+
return RenameFileResultFailure(failure_reason=FileIOFailureReason.UNKNOWN, result_details=msg)
|
|
1129
|
+
|
|
1130
|
+
# SUCCESS PATH
|
|
1131
|
+
details = f"Renamed: {old_path} -> {new_path}"
|
|
1132
|
+
return RenameFileResultSuccess(
|
|
1133
|
+
old_path=str(old_path),
|
|
1134
|
+
new_path=str(new_path),
|
|
1135
|
+
result_details=ResultDetails(message=details, level=logging.INFO),
|
|
1136
|
+
)
|
|
858
1137
|
|
|
859
1138
|
def on_app_initialization_complete(self, _payload: AppInitializationComplete) -> None:
|
|
860
1139
|
"""Handle app initialization complete event by registering system resources."""
|