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.
Files changed (41) hide show
  1. griptape_nodes/common/macro_parser/__init__.py +28 -0
  2. griptape_nodes/common/macro_parser/core.py +230 -0
  3. griptape_nodes/common/macro_parser/exceptions.py +23 -0
  4. griptape_nodes/common/macro_parser/formats.py +170 -0
  5. griptape_nodes/common/macro_parser/matching.py +134 -0
  6. griptape_nodes/common/macro_parser/parsing.py +172 -0
  7. griptape_nodes/common/macro_parser/resolution.py +168 -0
  8. griptape_nodes/common/macro_parser/segments.py +42 -0
  9. griptape_nodes/exe_types/core_types.py +241 -4
  10. griptape_nodes/exe_types/node_types.py +7 -1
  11. griptape_nodes/exe_types/param_components/huggingface/__init__.py +1 -0
  12. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +168 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +38 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_parameter.py +33 -0
  15. griptape_nodes/exe_types/param_components/huggingface/huggingface_utils.py +136 -0
  16. griptape_nodes/exe_types/param_components/log_parameter.py +136 -0
  17. griptape_nodes/exe_types/param_components/seed_parameter.py +59 -0
  18. griptape_nodes/exe_types/param_types/__init__.py +1 -0
  19. griptape_nodes/exe_types/param_types/parameter_bool.py +221 -0
  20. griptape_nodes/exe_types/param_types/parameter_float.py +179 -0
  21. griptape_nodes/exe_types/param_types/parameter_int.py +183 -0
  22. griptape_nodes/exe_types/param_types/parameter_number.py +380 -0
  23. griptape_nodes/exe_types/param_types/parameter_string.py +232 -0
  24. griptape_nodes/node_library/library_registry.py +2 -1
  25. griptape_nodes/retained_mode/events/app_events.py +21 -0
  26. griptape_nodes/retained_mode/events/os_events.py +142 -6
  27. griptape_nodes/retained_mode/events/parameter_events.py +2 -0
  28. griptape_nodes/retained_mode/griptape_nodes.py +14 -0
  29. griptape_nodes/retained_mode/managers/agent_manager.py +5 -3
  30. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +19 -1
  31. griptape_nodes/retained_mode/managers/library_manager.py +27 -32
  32. griptape_nodes/retained_mode/managers/node_manager.py +14 -1
  33. griptape_nodes/retained_mode/managers/os_manager.py +403 -124
  34. griptape_nodes/retained_mode/managers/user_manager.py +120 -0
  35. griptape_nodes/retained_mode/managers/workflow_manager.py +44 -34
  36. griptape_nodes/traits/multi_options.py +26 -2
  37. griptape_nodes/utils/huggingface_utils.py +136 -0
  38. {griptape_nodes-0.59.2.dist-info → griptape_nodes-0.60.0.dist-info}/METADATA +1 -1
  39. {griptape_nodes-0.59.2.dist-info → griptape_nodes-0.60.0.dist-info}/RECORD +41 -18
  40. {griptape_nodes-0.59.2.dist-info → griptape_nodes-0.60.0.dist-info}/WHEEL +1 -1
  41. {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(result_details=details)
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(result_details=details)
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(str(path)) # noqa: S606 # pyright: ignore[reportAttributeAccessIssue]
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", str(path)],
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(result_details=details)
357
+ return OpenAssociatedFileResultFailure(
358
+ failure_reason=FileIOFailureReason.IO_ERROR, result_details=details
359
+ )
307
360
 
308
361
  subprocess.run( # noqa: S603
309
- [xdg_path, str(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(result_details=details)
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(str(file_path), strict=True)
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"Directory is not a directory: {directory}"
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 (OSError, PermissionError) as e:
407
- msg = f"Error listing directory {directory}: {e}"
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
- # Initialize variables that might be used in exception handlers
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
- # Validate request and get file path
441
- file_path, file_path_str = self._validate_read_file_request(request)
442
-
443
- # Read file content
444
- content, encoding, mime_type, compression_encoding, file_size = self._read_file_content(file_path, request)
445
-
446
- return ReadFileResultSuccess(
447
- content=content,
448
- file_size=file_size,
449
- mime_type=mime_type,
450
- encoding=encoding,
451
- compression_encoding=compression_encoding,
452
- result_details="File read successfully.",
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
- except (ValueError, FileNotFoundError) as e:
456
- file_info = f" for file: {file_path}" if file_path is not None else ""
457
- msg = f"Validation error in read_file{file_info}: {e}"
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
- # Try to include file path in error message if available
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
- self, file_path: Path, request: ReadFileRequest
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
- mime_type, compression_encoding = mimetypes.guess_type(str(file_path), strict=True)
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(str(file_path))
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 content, encoding, mime_type, compression_encoding, file_size
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
- # Determine if path is absolute (not constrained to workspace)
764
- is_absolute = Path(full_path_str).is_absolute()
992
+ # Determine if path is absolute (not constrained to workspace)
993
+ is_absolute = Path(full_path_str).is_absolute()
765
994
 
766
- # If workspace_only is True and path is absolute, it's outside workspace
767
- if request.workspace_only and is_absolute:
768
- msg = f"Absolute path is outside workspace: {full_path_str}"
769
- logger.error(msg)
770
- return CreateFileResultFailure(result_details=msg)
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
- # Resolve path - if absolute, use as-is; if relative, align to workspace
773
- if is_absolute:
774
- file_path = Path(full_path_str).resolve()
775
- else:
776
- file_path = (self._get_workspace_path() / full_path_str).resolve()
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
- # Check if it already exists - warn but treat as success
779
- if file_path.exists():
780
- msg = f"Path already exists: {file_path}"
781
- return CreateFileResultSuccess(
782
- created_path=str(file_path), result_details=ResultDetails(message=msg, level=logging.WARNING)
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
- # Create parent directories if needed
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
- return CreateFileResultSuccess(
801
- created_path=str(file_path),
802
- result_details=f"{'Directory' if request.is_directory else 'File'} created successfully at {file_path}",
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
- path_info = request.get_full_path() if hasattr(request, "get_full_path") else str(request.path)
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
- def on_rename_file_request(self, request: RenameFileRequest) -> ResultPayload:
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
- # Resolve and validate new path
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
- # Check if old path exists
821
- if not old_path.exists():
822
- msg = f"Source path does not exist: {old_path}"
823
- logger.error(msg)
824
- return RenameFileResultFailure(result_details=msg)
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
- # Check if new path already exists
827
- if new_path.exists():
828
- msg = f"Destination path already exists: {new_path}"
829
- logger.error(msg)
830
- return RenameFileResultFailure(result_details=msg)
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
- # Check workspace constraints for both paths
833
- is_old_in_workspace, _ = self._validate_workspace_path(old_path)
834
- is_new_in_workspace, _ = self._validate_workspace_path(new_path)
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
- if request.workspace_only and (not is_old_in_workspace or not is_new_in_workspace):
837
- msg = f"One or both paths are outside workspace: {old_path} -> {new_path}"
838
- logger.error(msg)
839
- return RenameFileResultFailure(result_details=msg)
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
- # Create parent directories for new path if needed
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
- # Perform the rename operation
1114
+ # Perform the rename operation
1115
+ try:
845
1116
  old_path.rename(new_path)
846
- details = f"Renamed: {old_path} -> {new_path}"
847
-
848
- return RenameFileResultSuccess(
849
- old_path=str(old_path),
850
- new_path=str(new_path),
851
- result_details=ResultDetails(message=details, level=logging.INFO),
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"Failed to rename {request.old_path} to {request.new_path}: {e}"
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."""