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.
- {aiocortex-0.2.0/src/aiocortex.egg-info → aiocortex-0.3.0}/PKG-INFO +22 -2
- {aiocortex-0.2.0 → aiocortex-0.3.0}/README.md +21 -1
- {aiocortex-0.2.0 → aiocortex-0.3.0}/pyproject.toml +1 -1
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/__init__.py +32 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/_version.py +1 -1
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/files/manager.py +51 -21
- aiocortex-0.3.0/src/aiocortex/files/yaml_editor.py +235 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/manager.py +294 -50
- aiocortex-0.3.0/src/aiocortex/models/__init__.py +58 -0
- aiocortex-0.3.0/src/aiocortex/models/files.py +70 -0
- aiocortex-0.3.0/src/aiocortex/models/git.py +131 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0/src/aiocortex.egg-info}/PKG-INFO +22 -2
- aiocortex-0.2.0/src/aiocortex/files/yaml_editor.py +0 -52
- aiocortex-0.2.0/src/aiocortex/models/__init__.py +0 -19
- aiocortex-0.2.0/src/aiocortex/models/files.py +0 -24
- aiocortex-0.2.0/src/aiocortex/models/git.py +0 -36
- {aiocortex-0.2.0 → aiocortex-0.3.0}/LICENSE +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/setup.cfg +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/exceptions.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/files/__init__.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/__init__.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/cleanup.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/filters.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/git/sync.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/__init__.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/00_overview.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/01_explain_before_executing.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/02_output_formatting.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/03_critical_safety.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/04_dashboard_generation.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/05_api_summary.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/06_conditional_cards.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/instructions/docs/99_final_reminder.md +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/models/common.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/models/config.py +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex/py.typed +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/SOURCES.txt +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/dependency_links.txt +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/requires.txt +0 -0
- {aiocortex-0.2.0 → aiocortex-0.3.0}/src/aiocortex.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
@@ -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",
|
|
@@ -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[
|
|
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[
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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) ->
|
|
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
|
|
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) ->
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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) ->
|
|
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
|
|
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)
|