aiocortex 0.2.0__tar.gz → 0.3.0__tar.gz

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. {aiocortex-0.2.0/src/aiocortex.egg-info → aiocortex-0.3.0}/PKG-INFO +22 -2
  2. {aiocortex-0.2.0 → aiocortex-0.3.0}/README.md +21 -1
  3. {aiocortex-0.2.0 → aiocortex-0.3.0}/pyproject.toml +1 -1
  4. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/__init__.py +32 -0
  5. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/_version.py +1 -1
  6. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/files/manager.py +51 -21
  7. aiocortex-0.3.0/src/aiocortex/files/yaml_editor.py +235 -0
  8. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/manager.py +294 -50
  9. aiocortex-0.3.0/src/aiocortex/models/__init__.py +58 -0
  10. aiocortex-0.3.0/src/aiocortex/models/files.py +70 -0
  11. aiocortex-0.3.0/src/aiocortex/models/git.py +131 -0
  12. {aiocortex-0.2.0 → aiocortex-0.3.0/src/aiocortex.egg-info}/PKG-INFO +22 -2
  13. aiocortex-0.2.0/src/aiocortex/files/yaml_editor.py +0 -52
  14. aiocortex-0.2.0/src/aiocortex/models/__init__.py +0 -19
  15. aiocortex-0.2.0/src/aiocortex/models/files.py +0 -24
  16. aiocortex-0.2.0/src/aiocortex/models/git.py +0 -36
  17. {aiocortex-0.2.0 → aiocortex-0.3.0}/LICENSE +0 -0
  18. {aiocortex-0.2.0 → aiocortex-0.3.0}/setup.cfg +0 -0
  19. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/exceptions.py +0 -0
  20. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/files/__init__.py +0 -0
  21. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/__init__.py +0 -0
  22. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/cleanup.py +0 -0
  23. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/filters.py +0 -0
  24. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/sync.py +0 -0
  25. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/__init__.py +0 -0
  26. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/00_overview.md +0 -0
  27. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/01_explain_before_executing.md +0 -0
  28. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/02_output_formatting.md +0 -0
  29. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/03_critical_safety.md +0 -0
  30. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/04_dashboard_generation.md +0 -0
  31. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/05_api_summary.md +0 -0
  32. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/06_conditional_cards.md +0 -0
  33. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/99_final_reminder.md +0 -0
  34. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/models/common.py +0 -0
  35. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/models/config.py +0 -0
  36. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/py.typed +0 -0
  37. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/SOURCES.txt +0 -0
  38. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/dependency_links.txt +0 -0
  39. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/requires.txt +0 -0
  40. {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/top_level.txt +0 -0
  41. {aiocortex-0.2.0 → aiocortex-0.3.0}/tests/test_instructions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiocortex
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Async Python library for Home Assistant configuration management
5
5
  Author: Cortex Contributors
6
6
  License-Expression: MIT
@@ -47,8 +47,9 @@ pip install aiocortex
47
47
 
48
48
  - **Git versioning** — Shadow git repository for HA config backups using [dulwich](https://www.dulwich.io/) (pure Python, no git binary required)
49
49
  - **File management** — Async file operations with path security (directory traversal prevention)
50
- - **YAML editing** — Safe YAML read/write/parse utilities
50
+ - **YAML editing** — Safe YAML read/write/parse utilities + semantic patch preview/apply helpers
51
51
  - **Pydantic models** — Typed data models for automations, scripts, helpers, files, git commits
52
+ - **Transactions** — Durable plan/apply/abort flow with rollback metadata for multi-step config changes
52
53
  - **AI Instructions** — Bundled markdown instruction docs for AI-powered HA management (sync & async loaders)
53
54
 
54
55
  ## Quick Start
@@ -67,9 +68,23 @@ await git_mgr.init_repo()
67
68
  await git_mgr.commit_changes("Add automation: motion sensor light")
68
69
  history = await git_mgr.get_history(limit=10)
69
70
 
71
+ # Transaction operations
72
+ tx = await git_mgr.begin_transaction({"request": "update automation"})
73
+ await git_mgr.stage_file_write(tx.transaction_id, "automations.yaml", "- id: motion_1\n")
74
+ validation = await git_mgr.validate_transaction(tx.transaction_id)
75
+ if validation.valid:
76
+ await git_mgr.commit_transaction(tx.transaction_id, "Apply automation updates")
77
+
70
78
  # YAML editing
71
79
  editor = YAMLEditor()
72
80
  result = editor.remove_yaml_entry(content, "- id: 'old_automation'")
81
+
82
+ # Semantic YAML patch preview
83
+ from aiocortex.models import YAMLPatchOperation
84
+ preview = editor.preview_patch(
85
+ content,
86
+ [YAMLPatchOperation(op="set", path=["homeassistant", "name"], value="New Name")],
87
+ )
73
88
  ```
74
89
 
75
90
  ## Architecture
@@ -138,6 +153,11 @@ pip install -e ".[dev]"
138
153
  pytest --cov=aiocortex
139
154
  ```
140
155
 
156
+ ## Contract Changes (0.3.0)
157
+
158
+ - Public file/git manager methods now return strongly typed Pydantic models instead of raw dicts.
159
+ - Consumers should migrate from dictionary key access to attribute access.
160
+
141
161
  ## License
142
162
 
143
163
  MIT
@@ -14,8 +14,9 @@ pip install aiocortex
14
14
 
15
15
  - **Git versioning** — Shadow git repository for HA config backups using [dulwich](https://www.dulwich.io/) (pure Python, no git binary required)
16
16
  - **File management** — Async file operations with path security (directory traversal prevention)
17
- - **YAML editing** — Safe YAML read/write/parse utilities
17
+ - **YAML editing** — Safe YAML read/write/parse utilities + semantic patch preview/apply helpers
18
18
  - **Pydantic models** — Typed data models for automations, scripts, helpers, files, git commits
19
+ - **Transactions** — Durable plan/apply/abort flow with rollback metadata for multi-step config changes
19
20
  - **AI Instructions** — Bundled markdown instruction docs for AI-powered HA management (sync & async loaders)
20
21
 
21
22
  ## Quick Start
@@ -34,9 +35,23 @@ await git_mgr.init_repo()
34
35
  await git_mgr.commit_changes("Add automation: motion sensor light")
35
36
  history = await git_mgr.get_history(limit=10)
36
37
 
38
+ # Transaction operations
39
+ tx = await git_mgr.begin_transaction({"request": "update automation"})
40
+ await git_mgr.stage_file_write(tx.transaction_id, "automations.yaml", "- id: motion_1\n")
41
+ validation = await git_mgr.validate_transaction(tx.transaction_id)
42
+ if validation.valid:
43
+ await git_mgr.commit_transaction(tx.transaction_id, "Apply automation updates")
44
+
37
45
  # YAML editing
38
46
  editor = YAMLEditor()
39
47
  result = editor.remove_yaml_entry(content, "- id: 'old_automation'")
48
+
49
+ # Semantic YAML patch preview
50
+ from aiocortex.models import YAMLPatchOperation
51
+ preview = editor.preview_patch(
52
+ content,
53
+ [YAMLPatchOperation(op="set", path=["homeassistant", "name"], value="New Name")],
54
+ )
40
55
  ```
41
56
 
42
57
  ## Architecture
@@ -105,6 +120,11 @@ pip install -e ".[dev]"
105
120
  pytest --cov=aiocortex
106
121
  ```
107
122
 
123
+ ## Contract Changes (0.3.0)
124
+
125
+ - Public file/git manager methods now return strongly typed Pydantic models instead of raw dicts.
126
+ - Consumers should migrate from dictionary key access to attribute access.
127
+
108
128
  ## License
109
129
 
110
130
  MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiocortex"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Async Python library for Home Assistant configuration management"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -20,25 +20,46 @@ from .instructions import (
20
20
  )
21
21
  from .models import (
22
22
  AutomationConfig,
23
+ CheckpointResult,
24
+ CleanupResult,
23
25
  CommitInfo,
24
26
  CortexResponse,
27
+ FileAppendResult,
28
+ FileDeleteResult,
25
29
  FileInfo,
30
+ FilePathResult,
26
31
  FileWriteResult,
27
32
  HelperSpec,
28
33
  PendingChanges,
29
34
  PendingChangesSummary,
35
+ RestoreFilesResult,
36
+ RollbackResult,
30
37
  ScriptConfig,
31
38
  ServiceCallSpec,
39
+ TransactionAbortResult,
40
+ TransactionCommitResult,
41
+ TransactionOperation,
42
+ TransactionRollbackMetadata,
43
+ TransactionState,
44
+ TransactionValidationResult,
45
+ YAMLConflict,
46
+ YAMLPatchOperation,
47
+ YAMLPatchPreview,
32
48
  )
33
49
 
34
50
  __all__ = [
35
51
  "AsyncFileManager",
36
52
  "AutomationConfig",
53
+ "CheckpointResult",
54
+ "CleanupResult",
37
55
  "CommitInfo",
38
56
  "CortexError",
39
57
  "CortexResponse",
58
+ "FileAppendResult",
59
+ "FileDeleteResult",
40
60
  "FileError",
41
61
  "FileInfo",
62
+ "FilePathResult",
42
63
  "FileWriteResult",
43
64
  "GitError",
44
65
  "GitManager",
@@ -47,10 +68,21 @@ __all__ = [
47
68
  "PathSecurityError",
48
69
  "PendingChanges",
49
70
  "PendingChangesSummary",
71
+ "RestoreFilesResult",
72
+ "RollbackResult",
50
73
  "ScriptConfig",
51
74
  "ServiceCallSpec",
75
+ "TransactionAbortResult",
76
+ "TransactionCommitResult",
77
+ "TransactionOperation",
78
+ "TransactionRollbackMetadata",
79
+ "TransactionState",
80
+ "TransactionValidationResult",
81
+ "YAMLConflict",
52
82
  "YAMLEditor",
53
83
  "YAMLParseError",
84
+ "YAMLPatchOperation",
85
+ "YAMLPatchPreview",
54
86
  "__version__",
55
87
  "async_load_all_instructions",
56
88
  "async_load_instruction_file",
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -16,6 +16,15 @@ import aiofiles
16
16
  import yaml
17
17
 
18
18
  from ..exceptions import FileError, PathSecurityError, YAMLParseError
19
+ from ..models.files import (
20
+ FileAppendResult,
21
+ FileDeleteResult,
22
+ FileInfo,
23
+ FileWriteResult,
24
+ YAMLPatchOperation,
25
+ YAMLPatchPreview,
26
+ )
27
+ from .yaml_editor import YAMLEditor
19
28
 
20
29
  logger = logging.getLogger(__name__)
21
30
 
@@ -58,7 +67,7 @@ class AsyncFileManager:
58
67
  self,
59
68
  directory: str = "",
60
69
  pattern: str = "*",
61
- ) -> list[dict[str, object]]:
70
+ ) -> list[FileInfo]:
62
71
  """List files in *directory* matching *pattern* (recursive glob)."""
63
72
  try:
64
73
  dir_path = self._get_full_path(directory)
@@ -66,22 +75,22 @@ class AsyncFileManager:
66
75
  if not dir_path.exists():
67
76
  return []
68
77
 
69
- files: list[dict[str, object]] = []
78
+ files: list[FileInfo] = []
70
79
  for item in dir_path.rglob(pattern):
71
80
  if item.is_file():
72
81
  rel_path = item.relative_to(self.config_path)
73
82
  stat = item.stat()
74
83
  files.append(
75
- {
76
- "path": str(rel_path),
77
- "name": item.name,
78
- "size": stat.st_size,
79
- "modified": stat.st_mtime,
80
- "is_yaml": item.suffix in (".yaml", ".yml"),
81
- }
84
+ FileInfo(
85
+ path=str(rel_path),
86
+ name=item.name,
87
+ size=stat.st_size,
88
+ modified=stat.st_mtime,
89
+ is_yaml=item.suffix in (".yaml", ".yml"),
90
+ )
82
91
  )
83
92
 
84
- return sorted(files, key=lambda x: str(x["path"]))
93
+ return sorted(files, key=lambda file_info: file_info.path)
85
94
  except PathSecurityError:
86
95
  raise
87
96
  except Exception as exc:
@@ -107,7 +116,7 @@ class AsyncFileManager:
107
116
  logger.error("Error reading file %s: %s", file_path, exc)
108
117
  raise FileError(str(exc)) from exc
109
118
 
110
- async def write_file(self, file_path: str, content: str) -> dict[str, object]:
119
+ async def write_file(self, file_path: str, content: str) -> FileWriteResult:
111
120
  """Write *content* to *file_path*, creating parent directories as needed.
112
121
 
113
122
  Returns a result dict with *success*, *path*, and *size*.
@@ -126,14 +135,14 @@ class AsyncFileManager:
126
135
 
127
136
  logger.info("Wrote file: %s (%d bytes)", file_path, len(content))
128
137
 
129
- return {"success": True, "path": file_path, "size": len(content)}
138
+ return FileWriteResult(success=True, path=file_path, size=len(content))
130
139
  except PathSecurityError:
131
140
  raise
132
141
  except Exception as exc:
133
142
  logger.error("Error writing file %s: %s", file_path, exc)
134
143
  raise FileError(str(exc)) from exc
135
144
 
136
- async def append_file(self, file_path: str, content: str) -> dict[str, object]:
145
+ async def append_file(self, file_path: str, content: str) -> FileAppendResult:
137
146
  """Append *content* to *file_path*, creating it if it doesn't exist."""
138
147
  try:
139
148
  full_path = self._get_full_path(file_path)
@@ -152,19 +161,19 @@ class AsyncFileManager:
152
161
 
153
162
  logger.info("Appended to file: %s (%d bytes)", file_path, len(content))
154
163
 
155
- return {
156
- "success": True,
157
- "path": file_path,
158
- "added_bytes": len(content),
159
- "total_size": len(new_content),
160
- }
164
+ return FileAppendResult(
165
+ success=True,
166
+ path=file_path,
167
+ added_bytes=len(content),
168
+ total_size=len(new_content),
169
+ )
161
170
  except PathSecurityError:
162
171
  raise
163
172
  except Exception as exc:
164
173
  logger.error("Error appending to file %s: %s", file_path, exc)
165
174
  raise FileError(str(exc)) from exc
166
175
 
167
- async def delete_file(self, file_path: str) -> dict[str, object]:
176
+ async def delete_file(self, file_path: str) -> FileDeleteResult:
168
177
  """Delete *file_path*.
169
178
 
170
179
  Returns a result dict with *success* and *path*.
@@ -178,7 +187,7 @@ class AsyncFileManager:
178
187
  full_path.unlink()
179
188
  logger.info("Deleted file: %s", file_path)
180
189
 
181
- return {"success": True, "path": file_path}
190
+ return FileDeleteResult(success=True, path=file_path)
182
191
  except (FileNotFoundError, PathSecurityError):
183
192
  raise
184
193
  except Exception as exc:
@@ -194,3 +203,24 @@ class AsyncFileManager:
194
203
  except yaml.YAMLError as exc:
195
204
  logger.error("YAML parse error in %s: %s", file_path, exc)
196
205
  raise YAMLParseError(f"Invalid YAML: {exc}") from exc
206
+
207
+ async def preview_yaml_patch(
208
+ self,
209
+ file_path: str,
210
+ operations: list[YAMLPatchOperation],
211
+ ) -> YAMLPatchPreview:
212
+ """Preview semantic YAML patch operations without writing file changes."""
213
+ content = await self.read_file(file_path)
214
+ return YAMLEditor.preview_patch(content, operations)
215
+
216
+ async def apply_yaml_patch(
217
+ self,
218
+ file_path: str,
219
+ operations: list[YAMLPatchOperation],
220
+ ) -> YAMLPatchPreview:
221
+ """Apply semantic YAML patch operations and persist updated YAML content."""
222
+ content = await self.read_file(file_path)
223
+ preview = YAMLEditor.apply_patch(content, operations)
224
+ if preview.success:
225
+ await self.write_file(file_path, preview.patched_content)
226
+ return preview
@@ -0,0 +1,235 @@
1
+ """YAML editor utility for safe YAML file modifications.
2
+
3
+ Ported from ``app/utils/yaml_editor.py`` in the HA Vibecode Agent add-on.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import difflib
10
+ import re
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from ..models.files import YAMLConflict, YAMLPatchOperation, YAMLPatchPreview
16
+
17
+
18
+ class YAMLEditor:
19
+ """Utility for editing YAML files while preserving structure."""
20
+
21
+ @staticmethod
22
+ def remove_lines_from_end(content: str, num_lines: int) -> str:
23
+ """Remove *num_lines* from the end of *content*."""
24
+ lines = content.rstrip().split("\n")
25
+ if num_lines >= len(lines):
26
+ return ""
27
+ return "\n".join(lines[:-num_lines]) + "\n"
28
+
29
+ @staticmethod
30
+ def remove_empty_yaml_section(content: str, section_name: str) -> str:
31
+ """Remove an empty YAML section (e.g. ``lovelace:`` with only empty sub-keys)."""
32
+ # Pattern: comment + section with only empty subsections
33
+ pattern = rf"\n# .*{section_name.title()}.*\n{section_name}:\s*\n\s+\w+:\s*\n(?=\S|\Z)"
34
+ content = re.sub(pattern, "\n", content, flags=re.IGNORECASE)
35
+
36
+ # Also try without a preceding comment
37
+ pattern = rf"\n{section_name}:\s*\n\s+\w+:\s*\n(?=\S|\Z)"
38
+ content = re.sub(pattern, "\n", content, flags=re.IGNORECASE)
39
+
40
+ return content
41
+
42
+ @staticmethod
43
+ def remove_yaml_entry(
44
+ content: str,
45
+ section: str,
46
+ key: str,
47
+ ) -> tuple[str, bool]:
48
+ """Remove a specific entry from a YAML section.
49
+
50
+ Returns ``(modified_content, was_found)``.
51
+ """
52
+ pattern = rf" {re.escape(key)}:\s*\n(?: .*\n)*"
53
+
54
+ if re.search(pattern, content):
55
+ modified = re.sub(pattern, "", content)
56
+ modified = YAMLEditor.remove_empty_yaml_section(modified, section)
57
+ return modified, True
58
+
59
+ return content, False
60
+
61
+ @staticmethod
62
+ def _get_path(data: Any, path: list[str | int]) -> tuple[bool, Any]:
63
+ current = data
64
+ for segment in path:
65
+ if isinstance(segment, int):
66
+ if not isinstance(current, list) or segment >= len(current):
67
+ return False, None
68
+ current = current[segment]
69
+ continue
70
+
71
+ if not isinstance(current, dict) or segment not in current:
72
+ return False, None
73
+ current = current[segment]
74
+
75
+ return True, current
76
+
77
+ @staticmethod
78
+ def _ensure_parent(data: Any, path: list[str | int]) -> tuple[bool, Any]:
79
+ current = data
80
+ for segment in path[:-1]:
81
+ if isinstance(segment, int):
82
+ if not isinstance(current, list):
83
+ return False, None
84
+ if segment >= len(current):
85
+ return False, None
86
+ current = current[segment]
87
+ else:
88
+ if not isinstance(current, dict):
89
+ return False, None
90
+ if segment not in current or not isinstance(current[segment], (dict, list)):
91
+ current[segment] = {}
92
+ current = current[segment]
93
+
94
+ return True, current
95
+
96
+ @staticmethod
97
+ def _set_path(data: Any, path: list[str | int], value: Any) -> bool:
98
+ if not path:
99
+ return False
100
+
101
+ ok, parent = YAMLEditor._ensure_parent(data, path)
102
+ if not ok:
103
+ return False
104
+
105
+ leaf = path[-1]
106
+ if isinstance(leaf, int):
107
+ if not isinstance(parent, list):
108
+ return False
109
+ if leaf == len(parent):
110
+ parent.append(value)
111
+ return True
112
+ if 0 <= leaf < len(parent):
113
+ parent[leaf] = value
114
+ return True
115
+ return False
116
+
117
+ if not isinstance(parent, dict):
118
+ return False
119
+ parent[leaf] = value
120
+ return True
121
+
122
+ @staticmethod
123
+ def _remove_path(data: Any, path: list[str | int]) -> bool:
124
+ if not path:
125
+ return False
126
+
127
+ ok, parent = YAMLEditor._ensure_parent(data, path)
128
+ if not ok:
129
+ return False
130
+
131
+ leaf = path[-1]
132
+ if isinstance(leaf, int):
133
+ if not isinstance(parent, list) or not (0 <= leaf < len(parent)):
134
+ return False
135
+ del parent[leaf]
136
+ return True
137
+
138
+ if not isinstance(parent, dict) or leaf not in parent:
139
+ return False
140
+ del parent[leaf]
141
+ return True
142
+
143
+ @staticmethod
144
+ def _merge_list_item(data: Any, path: list[str | int], value: Any, merge_key: str) -> bool:
145
+ ok, current = YAMLEditor._get_path(data, path)
146
+ if not ok or not isinstance(current, list) or not isinstance(value, dict):
147
+ return False
148
+
149
+ key_value = value.get(merge_key)
150
+ if key_value is None:
151
+ return False
152
+
153
+ for index, item in enumerate(current):
154
+ if isinstance(item, dict) and item.get(merge_key) == key_value:
155
+ current[index] = {**item, **value}
156
+ return True
157
+
158
+ current.append(value)
159
+ return True
160
+
161
+ @staticmethod
162
+ def normalized_diff(before: str, after: str) -> str:
163
+ """Return deterministic unified diff output for content comparisons."""
164
+ before_lines = before.rstrip().splitlines()
165
+ after_lines = after.rstrip().splitlines()
166
+ return "\n".join(
167
+ difflib.unified_diff(
168
+ before_lines,
169
+ after_lines,
170
+ fromfile="before.yaml",
171
+ tofile="after.yaml",
172
+ lineterm="",
173
+ )
174
+ )
175
+
176
+ @staticmethod
177
+ def preview_patch(
178
+ content: str,
179
+ operations: list[YAMLPatchOperation],
180
+ ) -> YAMLPatchPreview:
181
+ """Preview semantic YAML mutations and report conflicts before apply."""
182
+ try:
183
+ parsed = yaml.safe_load(content) if content.strip() else {}
184
+ except yaml.YAMLError as exc:
185
+ return YAMLPatchPreview(
186
+ success=False,
187
+ operations_applied=0,
188
+ conflicts=[YAMLConflict(path="<root>", reason=f"Invalid YAML: {exc}")],
189
+ patched_content=content,
190
+ diff="",
191
+ )
192
+
193
+ data = parsed or {}
194
+ mutated = copy.deepcopy(data)
195
+ conflicts: list[YAMLConflict] = []
196
+ applied = 0
197
+
198
+ for operation in operations:
199
+ if operation.op == "set":
200
+ ok = YAMLEditor._set_path(mutated, operation.path, operation.value)
201
+ elif operation.op == "remove":
202
+ ok = YAMLEditor._remove_path(mutated, operation.path)
203
+ elif not operation.merge_key:
204
+ ok = False
205
+ else:
206
+ ok = YAMLEditor._merge_list_item(
207
+ mutated,
208
+ operation.path,
209
+ operation.value,
210
+ operation.merge_key,
211
+ )
212
+
213
+ if ok:
214
+ applied += 1
215
+ else:
216
+ conflicts.append(
217
+ YAMLConflict(
218
+ path="/".join(str(part) for part in operation.path),
219
+ reason=f"Could not apply operation '{operation.op}'",
220
+ )
221
+ )
222
+
223
+ patched_content = yaml.safe_dump(mutated, sort_keys=True, allow_unicode=True)
224
+ return YAMLPatchPreview(
225
+ success=not conflicts,
226
+ operations_applied=applied,
227
+ conflicts=conflicts,
228
+ patched_content=patched_content,
229
+ diff=YAMLEditor.normalized_diff(content, patched_content),
230
+ )
231
+
232
+ @staticmethod
233
+ def apply_patch(content: str, operations: list[YAMLPatchOperation]) -> YAMLPatchPreview:
234
+ """Apply semantic YAML mutations, returning final content and diff metadata."""
235
+ return YAMLEditor.preview_patch(content, operations)