ripperdoc 0.3.0__py3-none-any.whl → 0.3.2__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 (40) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -1
  3. ripperdoc/cli/commands/agents_cmd.py +93 -53
  4. ripperdoc/cli/commands/mcp_cmd.py +3 -0
  5. ripperdoc/cli/commands/models_cmd.py +768 -283
  6. ripperdoc/cli/commands/permissions_cmd.py +107 -52
  7. ripperdoc/cli/commands/resume_cmd.py +61 -51
  8. ripperdoc/cli/commands/themes_cmd.py +31 -1
  9. ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
  10. ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
  11. ripperdoc/cli/ui/choice.py +376 -0
  12. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  13. ripperdoc/cli/ui/message_display.py +7 -0
  14. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  15. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  16. ripperdoc/cli/ui/panels.py +19 -4
  17. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  18. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  19. ripperdoc/cli/ui/provider_options.py +220 -80
  20. ripperdoc/cli/ui/rich_ui.py +91 -83
  21. ripperdoc/cli/ui/tips.py +89 -0
  22. ripperdoc/cli/ui/wizard.py +98 -45
  23. ripperdoc/core/config.py +3 -0
  24. ripperdoc/core/permissions.py +66 -104
  25. ripperdoc/core/providers/anthropic.py +11 -0
  26. ripperdoc/protocol/stdio.py +3 -1
  27. ripperdoc/tools/bash_tool.py +2 -0
  28. ripperdoc/tools/file_edit_tool.py +100 -181
  29. ripperdoc/tools/file_read_tool.py +101 -25
  30. ripperdoc/tools/multi_edit_tool.py +239 -91
  31. ripperdoc/tools/notebook_edit_tool.py +11 -29
  32. ripperdoc/utils/file_editing.py +164 -0
  33. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  34. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  35. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
  36. ripperdoc/cli/ui/interrupt_handler.py +0 -208
  37. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  38. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  39. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  40. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
@@ -5,7 +5,6 @@ Allows performing multiple exact string replacements in a single file atomically
5
5
 
6
6
  import difflib
7
7
  import os
8
- from pathlib import Path
9
8
  from typing import AsyncGenerator, Optional, List
10
9
  from textwrap import dedent
11
10
  from pydantic import BaseModel, Field
@@ -19,7 +18,13 @@ from ripperdoc.core.tool import (
19
18
  ValidationResult,
20
19
  )
21
20
  from ripperdoc.utils.log import get_logger
22
- from ripperdoc.utils.file_watch import record_snapshot
21
+ from ripperdoc.utils.file_editing import (
22
+ atomic_write_with_fallback,
23
+ open_locked_file,
24
+ resolve_input_path,
25
+ safe_record_snapshot,
26
+ select_write_encoding,
27
+ )
23
28
  from ripperdoc.tools.file_read_tool import detect_file_encoding
24
29
 
25
30
  logger = get_logger()
@@ -167,10 +172,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
167
172
  input_data: MultiEditToolInput,
168
173
  context: Optional[ToolUseContext] = None,
169
174
  ) -> ValidationResult:
170
- path = Path(input_data.file_path).expanduser()
171
- if not path.is_absolute():
172
- path = Path.cwd() / path
173
- resolved_path = str(path.resolve())
175
+ path, cache_key = resolve_input_path(input_data.file_path)
174
176
 
175
177
  # Ensure edits differ.
176
178
  for edit in input_data.edits:
@@ -197,7 +199,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
197
199
  # If file exists, check if it has been read before editing
198
200
  if path.exists() and not is_creation:
199
201
  file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
200
- file_snapshot = file_state_cache.get(resolved_path)
202
+ file_snapshot = file_state_cache.get(cache_key)
201
203
 
202
204
  if not file_snapshot:
203
205
  return ValidationResult(
@@ -208,7 +210,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
208
210
 
209
211
  # Check if file has been modified since it was read
210
212
  try:
211
- current_mtime = os.path.getmtime(resolved_path)
213
+ current_mtime = os.path.getmtime(cache_key)
212
214
  if current_mtime > file_snapshot.timestamp:
213
215
  return ValidationResult(
214
216
  result=False,
@@ -335,143 +337,289 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
335
337
  context: ToolUseContext,
336
338
  ) -> AsyncGenerator[ToolOutput, None]:
337
339
  """Apply multiple edits atomically."""
338
- file_path = Path(input_data.file_path).expanduser()
339
- if not file_path.is_absolute():
340
- file_path = Path.cwd() / file_path
341
- file_path = file_path.resolve()
340
+ resolved_path, cache_key = resolve_input_path(input_data.file_path)
341
+ file_state_cache = getattr(context, "file_state_cache", {})
342
+ file_snapshot = file_state_cache.get(cache_key)
342
343
 
343
- existing = file_path.exists()
344
+ existing = resolved_path.exists()
345
+ created = not existing
344
346
  original_content = ""
345
347
  file_encoding = "utf-8"
346
348
 
347
- # Detect file encoding if file exists
348
349
  if existing:
349
- detected_encoding, _ = detect_file_encoding(str(file_path))
350
+ detected_encoding, _ = detect_file_encoding(str(resolved_path))
350
351
  if detected_encoding:
351
352
  file_encoding = detected_encoding
352
353
 
353
- try:
354
- if existing:
355
- original_content = file_path.read_text(encoding=file_encoding)
356
- except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
357
- # pragma: no cover - unlikely permission issue
358
- logger.warning(
359
- "[multi_edit_tool] Error reading file before edits: %s: %s",
360
- type(exc).__name__,
361
- exc,
362
- extra={"file_path": str(file_path)},
363
- )
364
- output = MultiEditToolOutput(
365
- file_path=str(file_path),
366
- replacements_made=0,
367
- success=False,
368
- message=f"Error reading file: {exc}",
369
- )
370
- yield ToolResult(
371
- data=output, result_for_assistant=self.render_result_for_assistant(output)
354
+ applied = input_data.edits
355
+
356
+ if not existing:
357
+ try:
358
+ updated_content, total_replacements = self._apply_edits(original_content, applied)
359
+ except ValueError as exc:
360
+ output = MultiEditToolOutput(
361
+ file_path=str(resolved_path),
362
+ replacements_made=0,
363
+ success=False,
364
+ message=str(exc),
365
+ applied_edits=applied,
366
+ created=True,
367
+ )
368
+ yield ToolResult(
369
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
370
+ )
371
+ return
372
+
373
+ if updated_content == original_content:
374
+ output = MultiEditToolOutput(
375
+ file_path=str(resolved_path),
376
+ replacements_made=0,
377
+ success=False,
378
+ message="Edits produced no changes.",
379
+ applied_edits=applied,
380
+ created=True,
381
+ )
382
+ yield ToolResult(
383
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
384
+ )
385
+ return
386
+
387
+ # Ensure parent exists (validated earlier) and write the file.
388
+ resolved_path.parent.mkdir(parents=True, exist_ok=True)
389
+
390
+ write_encoding = select_write_encoding(
391
+ file_encoding,
392
+ updated_content,
393
+ resolved_path,
394
+ log_prefix="[multi_edit_tool]",
372
395
  )
373
- return
374
396
 
375
- applied = input_data.edits
376
- try:
377
- updated_content, total_replacements = self._apply_edits(original_content, applied)
378
- except ValueError as exc:
379
- output = MultiEditToolOutput(
380
- file_path=str(file_path),
381
- replacements_made=0,
382
- success=False,
383
- message=str(exc),
384
- applied_edits=applied,
385
- created=not existing and original_content == "",
397
+ try:
398
+ with open(resolved_path, "x", encoding=write_encoding) as handle:
399
+ handle.write(updated_content)
400
+ except FileExistsError:
401
+ output = MultiEditToolOutput(
402
+ file_path=str(resolved_path),
403
+ replacements_made=0,
404
+ success=False,
405
+ message=(
406
+ "File was created while preparing edits. "
407
+ "Read it first before attempting to edit it."
408
+ ),
409
+ applied_edits=applied,
410
+ created=False,
411
+ )
412
+ yield ToolResult(
413
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
414
+ )
415
+ return
416
+ except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
417
+ logger.warning(
418
+ "[multi_edit_tool] Error writing edited file: %s: %s",
419
+ type(exc).__name__,
420
+ exc,
421
+ extra={"file_path": str(resolved_path)},
422
+ )
423
+ output = MultiEditToolOutput(
424
+ file_path=str(resolved_path),
425
+ replacements_made=0,
426
+ success=False,
427
+ message=f"Error writing file: {exc}",
428
+ applied_edits=applied,
429
+ created=True,
430
+ )
431
+ yield ToolResult(
432
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
433
+ )
434
+ return
435
+
436
+ safe_record_snapshot(
437
+ cache_key,
438
+ updated_content,
439
+ file_state_cache,
440
+ encoding=write_encoding,
441
+ log_prefix="[multi_edit_tool]",
386
442
  )
387
- yield ToolResult(
388
- data=output, result_for_assistant=self.render_result_for_assistant(output)
443
+
444
+ diff_lines, diff_with_line_numbers, additions, deletions = self._build_diff(
445
+ original_content, updated_content, str(resolved_path)
389
446
  )
390
- return
391
447
 
392
- if updated_content == original_content:
393
448
  output = MultiEditToolOutput(
394
- file_path=str(file_path),
395
- replacements_made=0,
396
- success=False,
397
- message="Edits produced no changes.",
449
+ file_path=str(resolved_path),
450
+ replacements_made=total_replacements,
451
+ success=True,
452
+ message=(
453
+ f"Applied {len(applied)} edit(s) with {total_replacements} replacement(s) "
454
+ f"to {resolved_path}"
455
+ ),
456
+ additions=additions,
457
+ deletions=deletions,
458
+ diff_lines=diff_lines,
459
+ diff_with_line_numbers=diff_with_line_numbers,
398
460
  applied_edits=applied,
399
- created=not existing and original_content == "",
461
+ created=True,
400
462
  )
463
+
401
464
  yield ToolResult(
402
- data=output, result_for_assistant=self.render_result_for_assistant(output)
465
+ data=output,
466
+ result_for_assistant=self.render_result_for_assistant(output),
403
467
  )
404
468
  return
405
469
 
406
- # Ensure parent exists (validated earlier) and write the file.
407
- file_path.parent.mkdir(parents=True, exist_ok=True)
408
-
409
- # Verify content can be encoded, fall back to UTF-8 if needed
470
+ updated_content = ""
471
+ total_replacements = 0
410
472
  write_encoding = file_encoding
411
- try:
412
- updated_content.encode(file_encoding)
413
- except (UnicodeEncodeError, LookupError):
414
- logger.info(
415
- "New content cannot be encoded with %s, using UTF-8 for %s",
416
- file_encoding,
417
- str(file_path),
418
- )
419
- write_encoding = "utf-8"
420
473
 
421
474
  try:
422
- file_path.write_text(updated_content, encoding=write_encoding)
423
- try:
424
- record_snapshot(
425
- str(file_path),
475
+ with open_locked_file(resolved_path, file_encoding) as (
476
+ handle,
477
+ pre_lock_mtime,
478
+ post_lock_mtime,
479
+ ):
480
+ if pre_lock_mtime is not None and post_lock_mtime is not None:
481
+ if post_lock_mtime > pre_lock_mtime:
482
+ output = MultiEditToolOutput(
483
+ file_path=str(resolved_path),
484
+ replacements_made=0,
485
+ success=False,
486
+ message="File was modified while acquiring lock. Please retry.",
487
+ applied_edits=applied,
488
+ created=False,
489
+ )
490
+ yield ToolResult(
491
+ data=output,
492
+ result_for_assistant=self.render_result_for_assistant(output),
493
+ )
494
+ return
495
+
496
+ if file_snapshot and post_lock_mtime is not None:
497
+ if post_lock_mtime > file_snapshot.timestamp:
498
+ output = MultiEditToolOutput(
499
+ file_path=str(resolved_path),
500
+ replacements_made=0,
501
+ success=False,
502
+ message=(
503
+ "File has been modified since read, either by the user "
504
+ "or by a linter. Read it again before attempting to edit it."
505
+ ),
506
+ applied_edits=applied,
507
+ created=False,
508
+ )
509
+ yield ToolResult(
510
+ data=output,
511
+ result_for_assistant=self.render_result_for_assistant(output),
512
+ )
513
+ return
514
+
515
+ original_content = handle.read()
516
+
517
+ try:
518
+ updated_content, total_replacements = self._apply_edits(
519
+ original_content, applied
520
+ )
521
+ except ValueError as exc:
522
+ output = MultiEditToolOutput(
523
+ file_path=str(resolved_path),
524
+ replacements_made=0,
525
+ success=False,
526
+ message=str(exc),
527
+ applied_edits=applied,
528
+ created=False,
529
+ )
530
+ yield ToolResult(
531
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
532
+ )
533
+ return
534
+
535
+ if updated_content == original_content:
536
+ output = MultiEditToolOutput(
537
+ file_path=str(resolved_path),
538
+ replacements_made=0,
539
+ success=False,
540
+ message="Edits produced no changes.",
541
+ applied_edits=applied,
542
+ created=False,
543
+ )
544
+ yield ToolResult(
545
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
546
+ )
547
+ return
548
+
549
+ write_encoding = select_write_encoding(
550
+ file_encoding,
426
551
  updated_content,
427
- getattr(context, "file_state_cache", {}),
428
- encoding=write_encoding,
552
+ resolved_path,
553
+ log_prefix="[multi_edit_tool]",
429
554
  )
430
- except (OSError, IOError, RuntimeError) as exc:
431
- logger.warning(
432
- "[multi_edit_tool] Failed to record file snapshot: %s: %s",
433
- type(exc).__name__,
434
- exc,
435
- extra={"file_path": str(file_path)},
555
+ write_error = atomic_write_with_fallback(
556
+ handle,
557
+ resolved_path,
558
+ updated_content,
559
+ write_encoding,
560
+ original_content,
561
+ temp_prefix=".ripperdoc_multi_edit_",
562
+ log_prefix="[multi_edit_tool]",
563
+ conflict_message="File was modified during atomic write fallback. Please retry.",
436
564
  )
565
+ if write_error:
566
+ output = MultiEditToolOutput(
567
+ file_path=str(resolved_path),
568
+ replacements_made=0,
569
+ success=False,
570
+ message=write_error,
571
+ applied_edits=applied,
572
+ created=False,
573
+ )
574
+ yield ToolResult(
575
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
576
+ )
577
+ return
437
578
  except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
579
+ # pragma: no cover - unlikely permission issue
438
580
  logger.warning(
439
- "[multi_edit_tool] Error writing edited file: %s: %s",
581
+ "[multi_edit_tool] Error reading file before edits: %s: %s",
440
582
  type(exc).__name__,
441
583
  exc,
442
- extra={"file_path": str(file_path)},
584
+ extra={"file_path": str(resolved_path)},
443
585
  )
444
586
  output = MultiEditToolOutput(
445
- file_path=str(file_path),
587
+ file_path=str(resolved_path),
446
588
  replacements_made=0,
447
589
  success=False,
448
- message=f"Error writing file: {exc}",
449
- applied_edits=applied,
450
- created=not existing and original_content == "",
590
+ message=f"Error reading file: {exc}",
451
591
  )
452
592
  yield ToolResult(
453
593
  data=output, result_for_assistant=self.render_result_for_assistant(output)
454
594
  )
455
595
  return
456
596
 
597
+ safe_record_snapshot(
598
+ cache_key,
599
+ updated_content,
600
+ file_state_cache,
601
+ encoding=write_encoding,
602
+ log_prefix="[multi_edit_tool]",
603
+ )
604
+
457
605
  diff_lines, diff_with_line_numbers, additions, deletions = self._build_diff(
458
- original_content, updated_content, str(file_path)
606
+ original_content, updated_content, str(resolved_path)
459
607
  )
460
608
 
461
609
  output = MultiEditToolOutput(
462
- file_path=str(file_path),
610
+ file_path=str(resolved_path),
463
611
  replacements_made=total_replacements,
464
612
  success=True,
465
613
  message=(
466
614
  f"Applied {len(applied)} edit(s) with {total_replacements} replacement(s) "
467
- f"to {file_path}"
615
+ f"to {resolved_path}"
468
616
  ),
469
617
  additions=additions,
470
618
  deletions=deletions,
471
619
  diff_lines=diff_lines,
472
620
  diff_with_line_numbers=diff_with_line_numbers,
473
621
  applied_edits=applied,
474
- created=not existing and original_content == "",
622
+ created=created,
475
623
  )
476
624
 
477
625
  yield ToolResult(
@@ -4,10 +4,8 @@ Allows performing insert/replace/delete operations on Jupyter notebook cells.
4
4
  """
5
5
 
6
6
  import json
7
- import os
8
7
  import random
9
8
  import string
10
- from pathlib import Path
11
9
  from textwrap import dedent
12
10
  from typing import AsyncGenerator, List, Optional
13
11
  from pydantic import BaseModel, Field
@@ -21,18 +19,12 @@ from ripperdoc.core.tool import (
21
19
  ValidationResult,
22
20
  )
23
21
  from ripperdoc.utils.log import get_logger
24
- from ripperdoc.utils.file_watch import record_snapshot
22
+ from ripperdoc.utils.file_editing import resolve_input_path, safe_record_snapshot
25
23
 
26
24
 
27
25
  logger = get_logger()
28
26
 
29
27
 
30
- def _resolve_path(path_str: str) -> Path:
31
- """Return an absolute Path, interpreting relative paths from CWD."""
32
- path = Path(path_str).expanduser()
33
- return path if path.is_absolute() else Path.cwd() / path
34
-
35
-
36
28
  def _generate_cell_id() -> str:
37
29
  """Generate a short random cell id."""
38
30
  return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
@@ -137,8 +129,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
137
129
  async def validate_input(
138
130
  self, input_data: NotebookEditInput, context: Optional[ToolUseContext] = None
139
131
  ) -> ValidationResult:
140
- path = _resolve_path(input_data.notebook_path)
141
- resolved_path = str(path.resolve())
132
+ path, cache_key = resolve_input_path(input_data.notebook_path)
142
133
 
143
134
  if not path.exists():
144
135
  return ValidationResult(
@@ -175,7 +166,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
175
166
 
176
167
  # Check if file has been read before editing
177
168
  file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
178
- file_snapshot = file_state_cache.get(resolved_path)
169
+ file_snapshot = file_state_cache.get(cache_key)
179
170
 
180
171
  if not file_snapshot:
181
172
  return ValidationResult(
@@ -186,7 +177,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
186
177
 
187
178
  # Check if file has been modified since it was read
188
179
  try:
189
- current_mtime = os.path.getmtime(resolved_path)
180
+ current_mtime = os.path.getmtime(cache_key)
190
181
  if current_mtime > file_snapshot.timestamp:
191
182
  return ValidationResult(
192
183
  result=False,
@@ -249,7 +240,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
249
240
  async def call(
250
241
  self, input_data: NotebookEditInput, context: ToolUseContext
251
242
  ) -> AsyncGenerator[ToolOutput, None]:
252
- path = _resolve_path(input_data.notebook_path)
243
+ path, cache_key = resolve_input_path(input_data.notebook_path)
253
244
  mode = (input_data.edit_mode or "replace").lower()
254
245
  cell_type = (input_data.cell_type or "").lower() or None
255
246
  new_source = input_data.new_source
@@ -315,21 +306,12 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
315
306
  )
316
307
 
317
308
  path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
318
- # Use resolved absolute path to ensure consistency with validation lookup
319
- abs_notebook_path = str(path.resolve())
320
- try:
321
- record_snapshot(
322
- abs_notebook_path,
323
- json.dumps(nb_json, indent=1),
324
- getattr(context, "file_state_cache", {}),
325
- )
326
- except (OSError, IOError, RuntimeError) as exc:
327
- logger.warning(
328
- "[notebook_edit_tool] Failed to record file snapshot: %s: %s",
329
- type(exc).__name__,
330
- exc,
331
- extra={"file_path": abs_notebook_path},
332
- )
309
+ safe_record_snapshot(
310
+ cache_key,
311
+ json.dumps(nb_json, indent=1),
312
+ getattr(context, "file_state_cache", {}),
313
+ log_prefix="[notebook_edit_tool]",
314
+ )
333
315
 
334
316
  output = NotebookEditOutput(
335
317
  new_source=new_source,
@@ -0,0 +1,164 @@
1
+ """Shared helpers for safe file editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Generator, Optional, TextIO, Union
10
+
11
+ from ripperdoc.utils.log import get_logger
12
+ from ripperdoc.utils.file_watch import record_snapshot
13
+ from ripperdoc.utils.platform import HAS_FCNTL
14
+
15
+ logger = get_logger()
16
+
17
+ PathLike = Union[str, Path]
18
+
19
+
20
+ def resolve_input_path(input_path: str) -> tuple[Path, str]:
21
+ """Return resolved path plus cache key (abspath preserves symlink usage)."""
22
+ path = Path(input_path).expanduser()
23
+ if not path.is_absolute():
24
+ path = Path.cwd() / path
25
+ return path.resolve(), os.path.abspath(input_path)
26
+
27
+
28
+ def safe_record_snapshot(
29
+ file_path: str,
30
+ content: str,
31
+ cache: dict,
32
+ *,
33
+ encoding: str = "utf-8",
34
+ log_prefix: str = "",
35
+ ) -> None:
36
+ """Record file snapshot with shared error handling."""
37
+ try:
38
+ record_snapshot(
39
+ file_path,
40
+ content,
41
+ cache,
42
+ encoding=encoding,
43
+ )
44
+ except (OSError, IOError, RuntimeError) as exc:
45
+ prefix = f"{log_prefix} " if log_prefix else ""
46
+ logger.warning(
47
+ "%sFailed to record file snapshot: %s: %s",
48
+ prefix,
49
+ type(exc).__name__,
50
+ exc,
51
+ extra={"file_path": file_path},
52
+ )
53
+
54
+
55
+ @contextlib.contextmanager
56
+ def file_lock(file_handle: TextIO, exclusive: bool = True) -> Generator[None, None, None]:
57
+ """Acquire a file lock, with fallback for systems without fcntl."""
58
+ if not HAS_FCNTL:
59
+ yield
60
+ return
61
+
62
+ import fcntl
63
+
64
+ lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
65
+ try:
66
+ fcntl.flock(file_handle.fileno(), lock_type)
67
+ yield
68
+ finally:
69
+ with contextlib.suppress(OSError):
70
+ fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
71
+
72
+
73
+ @contextlib.contextmanager
74
+ def open_locked_file(
75
+ file_path: PathLike, encoding: str
76
+ ) -> Generator[tuple[TextIO, Optional[float], Optional[float]], None, None]:
77
+ """Open a file for read/write and acquire an exclusive lock."""
78
+ with open(str(file_path), "r+", encoding=encoding) as handle:
79
+ try:
80
+ pre_lock_mtime = os.fstat(handle.fileno()).st_mtime
81
+ except OSError:
82
+ pre_lock_mtime = None
83
+
84
+ with file_lock(handle, exclusive=True):
85
+ try:
86
+ post_lock_mtime = os.fstat(handle.fileno()).st_mtime
87
+ except OSError:
88
+ post_lock_mtime = None
89
+ yield handle, pre_lock_mtime, post_lock_mtime
90
+
91
+
92
+ def select_write_encoding(
93
+ file_encoding: str,
94
+ updated_content: str,
95
+ file_path: PathLike,
96
+ *,
97
+ log_prefix: str = "",
98
+ ) -> str:
99
+ """Return a safe encoding for writing updated content."""
100
+ try:
101
+ updated_content.encode(file_encoding)
102
+ return file_encoding
103
+ except (UnicodeEncodeError, LookupError):
104
+ prefix = f"{log_prefix} " if log_prefix else ""
105
+ logger.info(
106
+ "%sNew content cannot be encoded with %s, using UTF-8 for %s",
107
+ prefix,
108
+ file_encoding,
109
+ str(file_path),
110
+ )
111
+ return "utf-8"
112
+
113
+
114
+ def atomic_write_with_fallback(
115
+ handle: TextIO,
116
+ file_path: PathLike,
117
+ updated_content: str,
118
+ write_encoding: str,
119
+ original_content: str,
120
+ *,
121
+ temp_prefix: str,
122
+ log_prefix: str,
123
+ conflict_message: str,
124
+ ) -> Optional[str]:
125
+ """Atomically write content, falling back to in-place write if needed."""
126
+ try:
127
+ file_dir = os.path.dirname(str(file_path))
128
+ try:
129
+ fd, temp_path = tempfile.mkstemp(
130
+ dir=file_dir,
131
+ prefix=temp_prefix,
132
+ suffix=".tmp",
133
+ )
134
+ try:
135
+ with os.fdopen(fd, "w", encoding=write_encoding) as temp_f:
136
+ temp_f.write(updated_content)
137
+ original_stat = os.fstat(handle.fileno())
138
+ os.chmod(temp_path, original_stat.st_mode)
139
+ os.replace(temp_path, str(file_path))
140
+ except Exception:
141
+ with contextlib.suppress(OSError):
142
+ os.unlink(temp_path)
143
+ raise
144
+ except OSError as atomic_error:
145
+ handle.seek(0)
146
+ current_content = handle.read()
147
+ if current_content != original_content:
148
+ return conflict_message
149
+ handle.seek(0)
150
+ handle.truncate()
151
+ handle.write(updated_content)
152
+ prefix = f"{log_prefix} " if log_prefix else ""
153
+ logger.debug("%sAtomic write failed, used fallback: %s", prefix, atomic_error)
154
+ except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
155
+ prefix = f"{log_prefix} " if log_prefix else ""
156
+ logger.warning(
157
+ "%sError writing edited file: %s: %s",
158
+ prefix,
159
+ type(exc).__name__,
160
+ exc,
161
+ extra={"file_path": str(file_path)},
162
+ )
163
+ return f"Error writing file: {exc}"
164
+ return None