devsync 0.5.5__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 (84) hide show
  1. aiconfigkit/__init__.py +0 -0
  2. aiconfigkit/__main__.py +6 -0
  3. aiconfigkit/ai_tools/__init__.py +0 -0
  4. aiconfigkit/ai_tools/base.py +236 -0
  5. aiconfigkit/ai_tools/capability_registry.py +262 -0
  6. aiconfigkit/ai_tools/claude.py +91 -0
  7. aiconfigkit/ai_tools/claude_desktop.py +97 -0
  8. aiconfigkit/ai_tools/cline.py +92 -0
  9. aiconfigkit/ai_tools/copilot.py +92 -0
  10. aiconfigkit/ai_tools/cursor.py +109 -0
  11. aiconfigkit/ai_tools/detector.py +169 -0
  12. aiconfigkit/ai_tools/kiro.py +85 -0
  13. aiconfigkit/ai_tools/mcp_syncer.py +291 -0
  14. aiconfigkit/ai_tools/roo.py +110 -0
  15. aiconfigkit/ai_tools/translator.py +390 -0
  16. aiconfigkit/ai_tools/winsurf.py +102 -0
  17. aiconfigkit/cli/__init__.py +0 -0
  18. aiconfigkit/cli/delete.py +118 -0
  19. aiconfigkit/cli/download.py +274 -0
  20. aiconfigkit/cli/install.py +237 -0
  21. aiconfigkit/cli/install_new.py +937 -0
  22. aiconfigkit/cli/list.py +275 -0
  23. aiconfigkit/cli/main.py +454 -0
  24. aiconfigkit/cli/mcp_configure.py +232 -0
  25. aiconfigkit/cli/mcp_install.py +166 -0
  26. aiconfigkit/cli/mcp_sync.py +165 -0
  27. aiconfigkit/cli/package.py +383 -0
  28. aiconfigkit/cli/package_create.py +323 -0
  29. aiconfigkit/cli/package_install.py +472 -0
  30. aiconfigkit/cli/template.py +19 -0
  31. aiconfigkit/cli/template_backup.py +261 -0
  32. aiconfigkit/cli/template_init.py +499 -0
  33. aiconfigkit/cli/template_install.py +261 -0
  34. aiconfigkit/cli/template_list.py +172 -0
  35. aiconfigkit/cli/template_uninstall.py +146 -0
  36. aiconfigkit/cli/template_update.py +225 -0
  37. aiconfigkit/cli/template_validate.py +234 -0
  38. aiconfigkit/cli/tools.py +47 -0
  39. aiconfigkit/cli/uninstall.py +125 -0
  40. aiconfigkit/cli/update.py +309 -0
  41. aiconfigkit/core/__init__.py +0 -0
  42. aiconfigkit/core/checksum.py +211 -0
  43. aiconfigkit/core/component_detector.py +905 -0
  44. aiconfigkit/core/conflict_resolution.py +329 -0
  45. aiconfigkit/core/git_operations.py +539 -0
  46. aiconfigkit/core/mcp/__init__.py +1 -0
  47. aiconfigkit/core/mcp/credentials.py +279 -0
  48. aiconfigkit/core/mcp/manager.py +308 -0
  49. aiconfigkit/core/mcp/set_manager.py +1 -0
  50. aiconfigkit/core/mcp/validator.py +1 -0
  51. aiconfigkit/core/models.py +1661 -0
  52. aiconfigkit/core/package_creator.py +743 -0
  53. aiconfigkit/core/package_manifest.py +248 -0
  54. aiconfigkit/core/repository.py +298 -0
  55. aiconfigkit/core/secret_detector.py +438 -0
  56. aiconfigkit/core/template_manifest.py +283 -0
  57. aiconfigkit/core/version.py +201 -0
  58. aiconfigkit/storage/__init__.py +0 -0
  59. aiconfigkit/storage/library.py +429 -0
  60. aiconfigkit/storage/mcp_tracker.py +1 -0
  61. aiconfigkit/storage/package_tracker.py +234 -0
  62. aiconfigkit/storage/template_library.py +229 -0
  63. aiconfigkit/storage/template_tracker.py +296 -0
  64. aiconfigkit/storage/tracker.py +416 -0
  65. aiconfigkit/tui/__init__.py +5 -0
  66. aiconfigkit/tui/installer.py +511 -0
  67. aiconfigkit/utils/__init__.py +0 -0
  68. aiconfigkit/utils/atomic_write.py +90 -0
  69. aiconfigkit/utils/backup.py +169 -0
  70. aiconfigkit/utils/dotenv.py +128 -0
  71. aiconfigkit/utils/git_helpers.py +187 -0
  72. aiconfigkit/utils/logging.py +60 -0
  73. aiconfigkit/utils/namespace.py +134 -0
  74. aiconfigkit/utils/paths.py +205 -0
  75. aiconfigkit/utils/project.py +109 -0
  76. aiconfigkit/utils/streaming.py +216 -0
  77. aiconfigkit/utils/ui.py +194 -0
  78. aiconfigkit/utils/validation.py +187 -0
  79. devsync-0.5.5.dist-info/LICENSE +21 -0
  80. devsync-0.5.5.dist-info/METADATA +477 -0
  81. devsync-0.5.5.dist-info/RECORD +84 -0
  82. devsync-0.5.5.dist-info/WHEEL +5 -0
  83. devsync-0.5.5.dist-info/entry_points.txt +2 -0
  84. devsync-0.5.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,511 @@
1
+ """Textual TUI for installing instructions from library."""
2
+
3
+ from typing import Optional
4
+
5
+ from textual import on
6
+ from textual.app import App, ComposeResult
7
+ from textual.containers import Container, Horizontal, Vertical
8
+ from textual.screen import Screen
9
+ from textual.widgets import (
10
+ Button,
11
+ Checkbox,
12
+ DataTable,
13
+ Footer,
14
+ Header,
15
+ Input,
16
+ Label,
17
+ Select,
18
+ Static,
19
+ )
20
+
21
+ from aiconfigkit.ai_tools.detector import get_detector
22
+ from aiconfigkit.core.models import InstallationScope
23
+ from aiconfigkit.storage.library import LibraryManager
24
+ from aiconfigkit.utils.project import find_project_root
25
+
26
+
27
+ class InstructionInstallerScreen(Screen):
28
+ """Main screen for selecting and installing instructions."""
29
+
30
+ CSS = """
31
+ Screen {
32
+ background: $background;
33
+ }
34
+
35
+ #title-container {
36
+ height: 3;
37
+ background: $boost;
38
+ padding: 1;
39
+ border-bottom: solid $primary;
40
+ }
41
+
42
+ #app-title {
43
+ text-align: center;
44
+ text-style: bold;
45
+ }
46
+
47
+ #search-container {
48
+ height: 3;
49
+ padding: 0 1;
50
+ margin-top: 1;
51
+ }
52
+
53
+ #filter-container {
54
+ height: 3;
55
+ padding: 0 1;
56
+ }
57
+
58
+ #instructions-table {
59
+ height: 1fr;
60
+ }
61
+
62
+ #installation-settings {
63
+ height: auto;
64
+ padding: 1;
65
+ background: $panel;
66
+ border-top: solid $primary;
67
+ }
68
+
69
+ #tools-container {
70
+ height: auto;
71
+ padding: 0 1;
72
+ }
73
+
74
+ #scope-container {
75
+ height: auto;
76
+ padding: 0 1;
77
+ margin-bottom: 1;
78
+ }
79
+
80
+ #status-bar {
81
+ height: auto;
82
+ padding: 1;
83
+ background: $surface;
84
+ }
85
+
86
+ #actions-container {
87
+ height: 3;
88
+ padding: 0 1;
89
+ }
90
+
91
+ Button {
92
+ margin: 0 1;
93
+ }
94
+
95
+ Checkbox {
96
+ margin: 0 2;
97
+ }
98
+
99
+ .selected-row {
100
+ background: $accent;
101
+ }
102
+
103
+ .setting-label {
104
+ text-style: bold;
105
+ color: $primary;
106
+ }
107
+
108
+ .help-text {
109
+ color: $text-muted;
110
+ }
111
+ """
112
+
113
+ BINDINGS = [
114
+ ("escape", "quit", "Quit"),
115
+ ("space", "toggle_selection", "Toggle Selection"),
116
+ ("enter", "toggle_selection", "Toggle Selection"),
117
+ ("ctrl+a", "select_all", "Select All"),
118
+ ("ctrl+d", "deselect_all", "Deselect All"),
119
+ ("ctrl+l", "clear_search", "Clear Search"),
120
+ ("/", "focus_search", "Search"),
121
+ ]
122
+
123
+ def __init__(
124
+ self,
125
+ library: LibraryManager,
126
+ tool: Optional[str] = None,
127
+ ):
128
+ """
129
+ Initialize installer screen.
130
+
131
+ Args:
132
+ library: Library manager instance
133
+ tool: AI tool to install to (ignored - user must select)
134
+ """
135
+ super().__init__()
136
+ self.library = library
137
+ # Always use project scope
138
+ self.scope = InstallationScope.PROJECT
139
+ self.instructions = library.list_instructions()
140
+ self.filtered_instructions = self.instructions.copy()
141
+ self.selected_ids: set[str] = set()
142
+
143
+ # Get current directory info for display
144
+ from pathlib import Path
145
+
146
+ self.current_dir = Path.cwd()
147
+ self.project_root = find_project_root()
148
+
149
+ # Detect available AI tools
150
+ detector = get_detector()
151
+ self.available_tools = detector.detect_installed_tools()
152
+
153
+ # No default tool selection - user must explicitly select
154
+ self.selected_tools: set[str] = set()
155
+
156
+ def compose(self) -> ComposeResult:
157
+ """Create child widgets."""
158
+ yield Header(show_clock=True)
159
+
160
+ # Branded title section
161
+ with Container(id="title-container"):
162
+ yield Static(
163
+ "🎯 [bold cyan]InstructionKit[/bold cyan] [dim]│[/dim] " "Browse & Install Instructions",
164
+ id="app-title",
165
+ )
166
+
167
+ # Search container
168
+ with Container(id="search-container"):
169
+ yield Input(placeholder="🔍 Search instructions by name or description...", id="search-input")
170
+
171
+ # Filter container
172
+ with Horizontal(id="filter-container"):
173
+ # Repository filter
174
+ repo_options = [("All Repositories", "")]
175
+ repos = {inst.repo_namespace: inst.repo_name for inst in self.instructions}
176
+ repo_options.extend([(name, namespace) for namespace, name in repos.items()])
177
+
178
+ yield Label("Filter by Repo:")
179
+ yield Select(
180
+ options=repo_options,
181
+ value="",
182
+ id="repo-filter",
183
+ )
184
+
185
+ # Instructions table
186
+ yield DataTable(id="instructions-table")
187
+
188
+ # Installation Settings Section
189
+ with Container(id="installation-settings"):
190
+ yield Label("⚙️ Installation Settings (REQUIRED)", classes="setting-label")
191
+
192
+ # Show installation location info
193
+ with Vertical(id="scope-container"):
194
+ yield Label("Installation location:")
195
+
196
+ # Display where files will be installed
197
+ if self.project_root:
198
+ help_text = f"Files will be installed to: {self.project_root}/<tool-specific-dir>/rules/"
199
+ else:
200
+ help_text = f"Files will be installed to: {self.current_dir}/<tool-specific-dir>/rules/"
201
+ yield Static(help_text, id="scope-help", classes="help-text")
202
+
203
+ # Target tools selection
204
+ with Vertical(id="tools-container"):
205
+ yield Label("Install to which AI tools: *")
206
+ if self.available_tools:
207
+ for tool in self.available_tools:
208
+ tool_id = tool.tool_type.value
209
+ # Start with nothing checked - user must select
210
+ yield Checkbox(
211
+ f"{tool.tool_name}",
212
+ value=False,
213
+ id=f"tool-{tool_id}",
214
+ )
215
+ else:
216
+ yield Static("⚠️ No AI coding tools detected!", classes="help-text")
217
+
218
+ # Status bar
219
+ with Container(id="status-bar"):
220
+ yield Static("", id="status-text")
221
+
222
+ # Action buttons
223
+ with Horizontal(id="actions-container"):
224
+ yield Button("Cancel", variant="default", id="cancel-btn")
225
+ yield Button("Select All", variant="primary", id="select-all-btn")
226
+ yield Button("Clear Selection", variant="default", id="deselect-all-btn")
227
+ yield Button("📦 Install Selected", variant="success", id="install-btn")
228
+
229
+ yield Footer()
230
+
231
+ def on_mount(self) -> None:
232
+ """Set up the table when mounted."""
233
+ table = self.query_one("#instructions-table", DataTable)
234
+
235
+ # Add columns
236
+ table.add_column("☑", key="selected", width=3)
237
+ table.add_column("Name", key="name", width=25)
238
+ table.add_column("Description", key="description", width=40)
239
+ table.add_column("Repository", key="repo", width=20)
240
+ table.add_column("Author", key="author", width=15)
241
+ table.add_column("Ver", key="version", width=8)
242
+ table.add_column("Tags", key="tags", width=20)
243
+
244
+ # Populate table
245
+ self.refresh_table()
246
+ self.update_status()
247
+
248
+ # Set focus to the table instead of search input
249
+ table.focus()
250
+
251
+ def refresh_table(self) -> None:
252
+ """Refresh the table with filtered instructions."""
253
+ table = self.query_one("#instructions-table", DataTable)
254
+ table.clear()
255
+
256
+ for inst in self.filtered_instructions:
257
+ is_selected = inst.id in self.selected_ids
258
+ checkbox = "[✓]" if is_selected else "[ ]"
259
+
260
+ # Truncate long text
261
+ name = inst.name[:23] + "..." if len(inst.name) > 23 else inst.name
262
+ desc = inst.description[:38] + "..." if len(inst.description) > 38 else inst.description
263
+ repo = inst.repo_name[:18] + "..." if len(inst.repo_name) > 18 else inst.repo_name
264
+ author = inst.author[:13] + "..." if len(inst.author) > 13 else inst.author
265
+ tags = ", ".join(inst.tags[:2]) if inst.tags else "-"
266
+ if len(inst.tags) > 2:
267
+ tags += f" +{len(inst.tags) - 2}"
268
+
269
+ table.add_row(
270
+ checkbox,
271
+ name,
272
+ desc,
273
+ repo,
274
+ author,
275
+ inst.version,
276
+ tags,
277
+ key=inst.id,
278
+ )
279
+
280
+ def update_status(self) -> None:
281
+ """Update the status bar."""
282
+ status = self.query_one("#status-text", Static)
283
+ total = len(self.filtered_instructions)
284
+ selected_instructions = len(self.selected_ids)
285
+ selected_tools_count = len(self.selected_tools)
286
+
287
+ # Tools text
288
+ if selected_tools_count == 0:
289
+ tools_text = "⚠️ None selected"
290
+ elif selected_tools_count == len(self.available_tools):
291
+ tools_text = f"All {selected_tools_count} tools"
292
+ else:
293
+ tools_text = f"{selected_tools_count} tool(s)"
294
+
295
+ status.update(
296
+ f"Instructions: {selected_instructions} selected | {total} shown | "
297
+ f"Target: {tools_text} | Install to: Project"
298
+ )
299
+
300
+ def filter_instructions(
301
+ self,
302
+ search: str = "",
303
+ repo_namespace: str = "",
304
+ ) -> None:
305
+ """
306
+ Filter instructions based on criteria.
307
+
308
+ Args:
309
+ search: Search query
310
+ repo_namespace: Repository namespace filter
311
+ """
312
+ self.filtered_instructions = self.instructions.copy()
313
+
314
+ # Apply search filter
315
+ if search:
316
+ query = search.lower()
317
+ self.filtered_instructions = [
318
+ inst
319
+ for inst in self.filtered_instructions
320
+ if query in inst.name.lower() or query in inst.description.lower()
321
+ ]
322
+
323
+ # Apply repo filter
324
+ if repo_namespace:
325
+ self.filtered_instructions = [
326
+ inst for inst in self.filtered_instructions if inst.repo_namespace == repo_namespace
327
+ ]
328
+
329
+ self.refresh_table()
330
+ self.update_status()
331
+
332
+ @on(Input.Changed, "#search-input")
333
+ def on_search_changed(self, event: Input.Changed) -> None:
334
+ """Handle search input changes."""
335
+ repo_filter = self.query_one("#repo-filter", Select).value
336
+ # Convert to string, handling NoSelection or other non-string values
337
+ repo_filter_str = str(repo_filter) if repo_filter is not None else ""
338
+ self.filter_instructions(search=event.value, repo_namespace=repo_filter_str)
339
+
340
+ @on(Select.Changed, "#repo-filter")
341
+ def on_repo_filter_changed(self, event: Select.Changed) -> None:
342
+ """Handle repository filter changes."""
343
+ search = self.query_one("#search-input", Input).value
344
+ self.filter_instructions(search=search, repo_namespace=str(event.value))
345
+
346
+ @on(Checkbox.Changed)
347
+ def on_tool_checkbox_changed(self, event: Checkbox.Changed) -> None:
348
+ """Handle tool checkbox changes."""
349
+ # Extract tool name from checkbox ID (format: "tool-cursor")
350
+ checkbox_id = event.checkbox.id
351
+ if checkbox_id and checkbox_id.startswith("tool-"):
352
+ tool_name = checkbox_id[5:] # Remove "tool-" prefix
353
+
354
+ if event.value:
355
+ self.selected_tools.add(tool_name)
356
+ else:
357
+ self.selected_tools.discard(tool_name)
358
+
359
+ self.update_status()
360
+
361
+ @on(DataTable.RowSelected)
362
+ def on_row_selected(self, event: DataTable.RowSelected) -> None:
363
+ """Toggle selection when row is clicked."""
364
+ if event.row_key:
365
+ instruction_id = str(event.row_key.value)
366
+
367
+ if instruction_id in self.selected_ids:
368
+ self.selected_ids.remove(instruction_id)
369
+ else:
370
+ self.selected_ids.add(instruction_id)
371
+
372
+ self.refresh_table()
373
+ self.update_status()
374
+
375
+ @on(Button.Pressed, "#select-all-btn")
376
+ def action_select_all(self) -> None:
377
+ """Select all filtered instructions."""
378
+ self.selected_ids.update(inst.id for inst in self.filtered_instructions)
379
+ self.refresh_table()
380
+ self.update_status()
381
+
382
+ @on(Button.Pressed, "#deselect-all-btn")
383
+ def action_deselect_all(self) -> None:
384
+ """Deselect all instructions."""
385
+ self.selected_ids.clear()
386
+ self.refresh_table()
387
+ self.update_status()
388
+
389
+ def action_toggle_selection(self) -> None:
390
+ """Toggle selection of the currently highlighted row."""
391
+ table = self.query_one("#instructions-table", DataTable)
392
+
393
+ # Get the currently highlighted row
394
+ if table.cursor_row is not None and table.cursor_row >= 0:
395
+ # Get all row keys as a list
396
+ row_keys = list(table.rows.keys())
397
+ if table.cursor_row < len(row_keys):
398
+ row_key = row_keys[table.cursor_row]
399
+ # Access the .value property of the RowKey to get the actual instruction ID
400
+ instruction_id = str(row_key.value)
401
+
402
+ # Toggle selection
403
+ if instruction_id in self.selected_ids:
404
+ self.selected_ids.remove(instruction_id)
405
+ else:
406
+ self.selected_ids.add(instruction_id)
407
+
408
+ self.refresh_table()
409
+ self.update_status()
410
+
411
+ def action_clear_search(self) -> None:
412
+ """Clear search input."""
413
+ search_input = self.query_one("#search-input", Input)
414
+ search_input.value = ""
415
+ search_input.focus()
416
+
417
+ def action_focus_search(self) -> None:
418
+ """Focus the search input."""
419
+ self.query_one("#search-input", Input).focus()
420
+
421
+ @on(Button.Pressed, "#cancel-btn")
422
+ def action_quit(self) -> None:
423
+ """Cancel and exit."""
424
+ self.dismiss(None)
425
+
426
+ @on(Button.Pressed, "#install-btn")
427
+ def on_install_pressed(self) -> None:
428
+ """Handle install button press."""
429
+ # Validate all required selections
430
+ errors = []
431
+
432
+ if not self.selected_ids:
433
+ errors.append("Please select at least one instruction")
434
+
435
+ if not self.selected_tools:
436
+ errors.append("Please select at least one AI tool")
437
+
438
+ # Show all errors
439
+ if errors:
440
+ for error in errors:
441
+ self.app.notify(error, severity="error", timeout=4)
442
+ return
443
+
444
+ # Get selected instructions
445
+ selected_instructions = [inst for inst in self.instructions if inst.id in self.selected_ids]
446
+
447
+ # Return result with selected tools as a list
448
+ self.dismiss(
449
+ {
450
+ "instructions": selected_instructions,
451
+ "tools": list(self.selected_tools), # Return as list
452
+ }
453
+ )
454
+
455
+
456
+ class InstructionInstallerApp(App):
457
+ """Application for installing instructions."""
458
+
459
+ TITLE = "InstructionKit Installer"
460
+ SUB_TITLE = "Browse, Select & Install Instructions"
461
+
462
+ def __init__(
463
+ self,
464
+ library: LibraryManager,
465
+ tool: Optional[str] = None,
466
+ ):
467
+ """
468
+ Initialize installer app.
469
+
470
+ Args:
471
+ library: Library manager instance
472
+ tool: AI tool to install to (None = all)
473
+ """
474
+ super().__init__()
475
+ self.library = library
476
+ self.tool = tool
477
+ self.result: Optional[dict] = None
478
+
479
+ def on_mount(self) -> None:
480
+ """Push the installer screen when app mounts."""
481
+ screen = InstructionInstallerScreen(
482
+ library=self.library,
483
+ tool=self.tool,
484
+ )
485
+ self.push_screen(screen, self.handle_result)
486
+
487
+ def handle_result(self, result: Optional[dict]) -> None:
488
+ """Handle result from installer screen."""
489
+ self.result = result
490
+ self.exit()
491
+
492
+
493
+ def show_installer_tui(
494
+ library: LibraryManager,
495
+ tool: Optional[str] = None,
496
+ ) -> Optional[dict]:
497
+ """
498
+ Show the instruction installer TUI.
499
+
500
+ All installations are at project level.
501
+
502
+ Args:
503
+ library: Library manager instance
504
+ tool: AI tool to install to (None = all)
505
+
506
+ Returns:
507
+ Dictionary with selected instructions and settings, or None if cancelled
508
+ """
509
+ app = InstructionInstallerApp(library=library, tool=tool)
510
+ app.run()
511
+ return app.result
File without changes
@@ -0,0 +1,90 @@
1
+ """Atomic file write utilities for safe config file updates."""
2
+
3
+ import os
4
+ import tempfile
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Generator
8
+
9
+
10
+ @contextmanager
11
+ def atomic_write(
12
+ file_path: Path,
13
+ mode: str = "w",
14
+ encoding: str = "utf-8",
15
+ create_backup: bool = True,
16
+ ) -> Generator:
17
+ """
18
+ Context manager for atomic file writes using temp file + os.replace().
19
+
20
+ This ensures that file writes are atomic - if the write fails, the original
21
+ file is left unchanged. If create_backup is True, creates a backup before
22
+ replacing the original file.
23
+
24
+ Args:
25
+ file_path: Path to file to write
26
+ mode: File open mode ("w" for text, "wb" for binary)
27
+ encoding: Text encoding (ignored for binary mode)
28
+ create_backup: Whether to create .bak backup before replacing
29
+
30
+ Yields:
31
+ File object for writing
32
+
33
+ Example:
34
+ with atomic_write(Path("config.json")) as f:
35
+ json.dump(config, f)
36
+ """
37
+ file_path = Path(file_path).resolve()
38
+ file_path.parent.mkdir(parents=True, exist_ok=True)
39
+
40
+ # Create backup if requested and file exists
41
+ backup_path = None
42
+ if create_backup and file_path.exists():
43
+ backup_path = file_path.with_suffix(file_path.suffix + ".bak")
44
+ # Copy original to backup
45
+ import shutil
46
+
47
+ shutil.copy2(file_path, backup_path)
48
+
49
+ # Create temporary file in same directory as target
50
+ # (ensures atomic rename on all platforms)
51
+ fd, temp_path = tempfile.mkstemp(dir=file_path.parent, prefix=f".{file_path.name}.", suffix=".tmp")
52
+
53
+ try:
54
+ # Open temp file with requested mode
55
+ if "b" in mode:
56
+ # Binary mode
57
+ temp_file = os.fdopen(fd, mode)
58
+ else:
59
+ # Text mode
60
+ temp_file = os.fdopen(fd, mode, encoding=encoding)
61
+
62
+ with temp_file as f:
63
+ yield f
64
+ # Ensure data is written to disk
65
+ f.flush()
66
+ os.fsync(f.fileno())
67
+
68
+ # Atomic rename (replaces target file)
69
+ # os.replace() is atomic on all platforms
70
+ os.replace(temp_path, file_path)
71
+
72
+ except Exception:
73
+ # Clean up temp file on error
74
+ try:
75
+ os.unlink(temp_path)
76
+ except OSError:
77
+ pass
78
+
79
+ # Restore from backup if available
80
+ if backup_path and backup_path.exists():
81
+ import shutil
82
+
83
+ shutil.copy2(backup_path, file_path)
84
+
85
+ raise
86
+
87
+ finally:
88
+ # Clean up backup file (optional - could keep for user reference)
89
+ # For now, keep backups for safety
90
+ pass