aiocortex 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Cortex Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiocortex
3
+ Version: 0.1.0
4
+ Summary: Async Python library for Home Assistant configuration management
5
+ Author: Cortex Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ruaan-deysel/aiocortex
8
+ Project-URL: Repository, https://github.com/ruaan-deysel/aiocortex
9
+ Project-URL: Issues, https://github.com/ruaan-deysel/aiocortex/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Home Automation
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: pydantic<3.0,>=2.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Requires-Dist: aiofiles>=23.0
23
+ Requires-Dist: dulwich>=0.22.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
27
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
28
+ Requires-Dist: mypy>=1.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
30
+ Requires-Dist: types-aiofiles>=23.0; extra == "dev"
31
+ Requires-Dist: types-PyYAML>=6.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # aiocortex
35
+
36
+ Async Python library for Home Assistant configuration management. Provides git versioning (via dulwich), file management, YAML editing, and Pydantic models — all independent of Home Assistant internals.
37
+
38
+ This library is the core engine behind the [Cortex](https://github.com/ruaan-deysel/ha-cortex) HACS integration.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install aiocortex
44
+ ```
45
+
46
+ ## Features
47
+
48
+ - **Git versioning** — Shadow git repository for HA config backups using [dulwich](https://www.dulwich.io/) (pure Python, no git binary required)
49
+ - **File management** — Async file operations with path security (directory traversal prevention)
50
+ - **YAML editing** — Safe YAML read/write/parse utilities
51
+ - **Pydantic models** — Typed data models for automations, scripts, helpers, files, git commits
52
+
53
+ ## Quick Start
54
+
55
+ ```python
56
+ from aiocortex import AsyncFileManager, GitManager, YAMLEditor
57
+
58
+ # File operations
59
+ file_mgr = AsyncFileManager(config_path=Path("/config"))
60
+ files = await file_mgr.list_files("", "*.yaml")
61
+ content = await file_mgr.read_file("automations.yaml")
62
+
63
+ # Git versioning
64
+ git_mgr = GitManager(config_path=Path("/config"), max_backups=30)
65
+ await git_mgr.init_repo()
66
+ await git_mgr.commit_changes("Add automation: motion sensor light")
67
+ history = await git_mgr.get_history(limit=10)
68
+
69
+ # YAML editing
70
+ editor = YAMLEditor()
71
+ result = editor.remove_yaml_entry(content, "- id: 'old_automation'")
72
+ ```
73
+
74
+ ## Architecture
75
+
76
+ ```
77
+ aiocortex/
78
+ ├── git/ # GitManager, sync, filters, cleanup (dulwich-based)
79
+ ├── files/ # AsyncFileManager, YAMLEditor
80
+ ├── models/ # Pydantic v2 models (common, config, files, git)
81
+ └── exceptions.py # CortexError hierarchy
82
+ ```
83
+
84
+ ### Design Principle
85
+
86
+ If it imports `homeassistant.*`, it does **not** belong here. This library contains only HA-independent logic. The Cortex integration handles all HA-specific concerns (states, services, registries, auth, HTTP routing).
87
+
88
+ ## Models
89
+
90
+ ```python
91
+ from aiocortex.models import (
92
+ AutomationConfig, # Automation definition
93
+ ScriptConfig, # Script definition
94
+ HelperSpec, # Input helper specification
95
+ ServiceCallSpec, # Service call parameters
96
+ FileInfo, # File metadata
97
+ FileWriteResult, # Write operation result
98
+ CommitInfo, # Git commit metadata
99
+ PendingChanges, # Uncommitted changes
100
+ CortexResponse, # Standard API response
101
+ )
102
+ ```
103
+
104
+ ## Dependencies
105
+
106
+ - `pydantic>=2.0` — Data validation and models
107
+ - `pyyaml>=6.0` — YAML parsing
108
+ - `aiofiles>=23.0` — Async file I/O
109
+ - `dulwich>=0.22.0` — Pure Python git implementation
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ git clone https://github.com/ruaan-deysel/aiocortex.git
115
+ cd aiocortex
116
+ python -m venv .venv && source .venv/bin/activate
117
+ pip install -e ".[dev]"
118
+ pytest --cov=aiocortex
119
+ ```
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,90 @@
1
+ # aiocortex
2
+
3
+ Async Python library for Home Assistant configuration management. Provides git versioning (via dulwich), file management, YAML editing, and Pydantic models — all independent of Home Assistant internals.
4
+
5
+ This library is the core engine behind the [Cortex](https://github.com/ruaan-deysel/ha-cortex) HACS integration.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install aiocortex
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **Git versioning** — Shadow git repository for HA config backups using [dulwich](https://www.dulwich.io/) (pure Python, no git binary required)
16
+ - **File management** — Async file operations with path security (directory traversal prevention)
17
+ - **YAML editing** — Safe YAML read/write/parse utilities
18
+ - **Pydantic models** — Typed data models for automations, scripts, helpers, files, git commits
19
+
20
+ ## Quick Start
21
+
22
+ ```python
23
+ from aiocortex import AsyncFileManager, GitManager, YAMLEditor
24
+
25
+ # File operations
26
+ file_mgr = AsyncFileManager(config_path=Path("/config"))
27
+ files = await file_mgr.list_files("", "*.yaml")
28
+ content = await file_mgr.read_file("automations.yaml")
29
+
30
+ # Git versioning
31
+ git_mgr = GitManager(config_path=Path("/config"), max_backups=30)
32
+ await git_mgr.init_repo()
33
+ await git_mgr.commit_changes("Add automation: motion sensor light")
34
+ history = await git_mgr.get_history(limit=10)
35
+
36
+ # YAML editing
37
+ editor = YAMLEditor()
38
+ result = editor.remove_yaml_entry(content, "- id: 'old_automation'")
39
+ ```
40
+
41
+ ## Architecture
42
+
43
+ ```
44
+ aiocortex/
45
+ ├── git/ # GitManager, sync, filters, cleanup (dulwich-based)
46
+ ├── files/ # AsyncFileManager, YAMLEditor
47
+ ├── models/ # Pydantic v2 models (common, config, files, git)
48
+ └── exceptions.py # CortexError hierarchy
49
+ ```
50
+
51
+ ### Design Principle
52
+
53
+ If it imports `homeassistant.*`, it does **not** belong here. This library contains only HA-independent logic. The Cortex integration handles all HA-specific concerns (states, services, registries, auth, HTTP routing).
54
+
55
+ ## Models
56
+
57
+ ```python
58
+ from aiocortex.models import (
59
+ AutomationConfig, # Automation definition
60
+ ScriptConfig, # Script definition
61
+ HelperSpec, # Input helper specification
62
+ ServiceCallSpec, # Service call parameters
63
+ FileInfo, # File metadata
64
+ FileWriteResult, # Write operation result
65
+ CommitInfo, # Git commit metadata
66
+ PendingChanges, # Uncommitted changes
67
+ CortexResponse, # Standard API response
68
+ )
69
+ ```
70
+
71
+ ## Dependencies
72
+
73
+ - `pydantic>=2.0` — Data validation and models
74
+ - `pyyaml>=6.0` — YAML parsing
75
+ - `aiofiles>=23.0` — Async file I/O
76
+ - `dulwich>=0.22.0` — Pure Python git implementation
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ git clone https://github.com/ruaan-deysel/aiocortex.git
82
+ cd aiocortex
83
+ python -m venv .venv && source .venv/bin/activate
84
+ pip install -e ".[dev]"
85
+ pytest --cov=aiocortex
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,123 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aiocortex"
7
+ version = "0.1.0"
8
+ description = "Async Python library for Home Assistant configuration management"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ authors = [
13
+ {name = "Cortex Contributors"},
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Home Automation",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ dependencies = [
26
+ "pydantic>=2.0,<3.0",
27
+ "pyyaml>=6.0",
28
+ "aiofiles>=23.0",
29
+ "dulwich>=0.22.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/ruaan-deysel/aiocortex"
34
+ Repository = "https://github.com/ruaan-deysel/aiocortex"
35
+ Issues = "https://github.com/ruaan-deysel/aiocortex/issues"
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=8.0",
40
+ "pytest-asyncio>=0.23",
41
+ "pytest-cov>=4.0",
42
+ "mypy>=1.0",
43
+ "ruff>=0.1.0",
44
+ "types-aiofiles>=23.0",
45
+ "types-PyYAML>=6.0",
46
+ ]
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
50
+
51
+ [tool.setuptools.package-data]
52
+ aiocortex = ["py.typed"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ asyncio_mode = "auto"
57
+
58
+ [tool.ruff]
59
+ required-version = ">=0.15.1"
60
+ target-version = "py312"
61
+ line-length = 99
62
+
63
+ [tool.ruff.lint]
64
+ select = [
65
+ "A001", # Variable shadowing a Python builtin
66
+ "ASYNC", # flake8-async
67
+ "B", # flake8-bugbear
68
+ "BLE", # flake8-blind-except
69
+ "C", # complexity
70
+ "E", # pycodestyle
71
+ "F", # pyflakes
72
+ "FLY", # flynt
73
+ "G", # flake8-logging-format
74
+ "I", # isort
75
+ "ICN001", # import conventions
76
+ "LOG", # flake8-logging
77
+ "N804", # First argument of a class method should be named cls
78
+ "N805", # First argument of a method should be named self
79
+ "PERF", # perflint
80
+ "PGH", # pygrep-hooks
81
+ "PIE", # flake8-pie
82
+ "PL", # pylint
83
+ "PT", # flake8-pytest-style
84
+ "RET", # flake8-return
85
+ "RSE", # flake8-raise
86
+ "RUF", # ruff-specific rules
87
+ "T20", # flake8-print
88
+ "UP", # pyupgrade
89
+ "W", # pycodestyle warnings
90
+ ]
91
+ ignore = [
92
+ "BLE001", # Blind exception catch — intentional in cleanup/fallback code
93
+ "C901", # Too complex — ported code, complexity is inherent
94
+ "PLC0415", # Import not at top-level — intentional lazy imports
95
+ "PERF401", # Use list comprehension — clarity preferred in some cases
96
+ "PLR0911", # Too many return statements
97
+ "PLR0912", # Too many branches
98
+ "PLR0913", # Too many arguments
99
+ "PLR0915", # Too many statements
100
+ "PLR2004", # Magic value comparison
101
+ "PLW1510", # subprocess.run without check — we check return codes manually
102
+ "RET504", # Unnecessary assignment before return
103
+ ]
104
+
105
+ [tool.ruff.lint.isort]
106
+ known-first-party = ["aiocortex"]
107
+
108
+ [tool.ruff.lint.per-file-ignores]
109
+ "tests/**" = ["D", "PLR2004", "PT", "F841"]
110
+
111
+ [tool.mypy]
112
+ python_version = "3.12"
113
+ strict = true
114
+
115
+ [[tool.mypy.overrides]]
116
+ module = ["dulwich.*"]
117
+ ignore_missing_imports = true
118
+
119
+ [[tool.mypy.overrides]]
120
+ module = ["aiocortex.git.*"]
121
+ disallow_any_expr = false
122
+ warn_return_any = false
123
+ disable_error_code = ["attr-defined", "arg-type", "assignment", "union-attr"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,48 @@
1
+ """aiocortex — Async Python library for Home Assistant configuration management."""
2
+
3
+ from ._version import __version__
4
+ from .exceptions import (
5
+ CortexError,
6
+ FileError,
7
+ GitError,
8
+ GitNotInitializedError,
9
+ PathSecurityError,
10
+ YAMLParseError,
11
+ )
12
+ from .files import AsyncFileManager, YAMLEditor
13
+ from .git import GitManager
14
+ from .models import (
15
+ AutomationConfig,
16
+ CommitInfo,
17
+ CortexResponse,
18
+ FileInfo,
19
+ FileWriteResult,
20
+ HelperSpec,
21
+ PendingChanges,
22
+ PendingChangesSummary,
23
+ ScriptConfig,
24
+ ServiceCallSpec,
25
+ )
26
+
27
+ __all__ = [
28
+ "AsyncFileManager",
29
+ "AutomationConfig",
30
+ "CommitInfo",
31
+ "CortexError",
32
+ "CortexResponse",
33
+ "FileError",
34
+ "FileInfo",
35
+ "FileWriteResult",
36
+ "GitError",
37
+ "GitManager",
38
+ "GitNotInitializedError",
39
+ "HelperSpec",
40
+ "PathSecurityError",
41
+ "PendingChanges",
42
+ "PendingChangesSummary",
43
+ "ScriptConfig",
44
+ "ServiceCallSpec",
45
+ "YAMLEditor",
46
+ "YAMLParseError",
47
+ "__version__",
48
+ ]
@@ -0,0 +1,3 @@
1
+ """Version information."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,25 @@
1
+ """Exception hierarchy for aiocortex."""
2
+
3
+
4
+ class CortexError(Exception):
5
+ """Base exception for all aiocortex errors."""
6
+
7
+
8
+ class GitError(CortexError):
9
+ """Error during a git operation."""
10
+
11
+
12
+ class GitNotInitializedError(GitError):
13
+ """The shadow git repository has not been initialized."""
14
+
15
+
16
+ class FileError(CortexError):
17
+ """Error during a file operation."""
18
+
19
+
20
+ class PathSecurityError(FileError):
21
+ """A requested path resolved outside the allowed config directory."""
22
+
23
+
24
+ class YAMLParseError(FileError):
25
+ """Failed to parse a YAML file."""
@@ -0,0 +1,6 @@
1
+ """File management utilities."""
2
+
3
+ from .manager import AsyncFileManager
4
+ from .yaml_editor import YAMLEditor
5
+
6
+ __all__ = ["AsyncFileManager", "YAMLEditor"]
@@ -0,0 +1,196 @@
1
+ """Async file manager for Home Assistant configuration files.
2
+
3
+ Ported from ``app/services/file_manager.py`` in the HA Vibecode Agent add-on.
4
+ This version is *HA-independent*: it receives ``config_path`` via its
5
+ constructor and never imports git — the integration layer is responsible for
6
+ orchestrating git commits after file operations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import aiofiles
16
+ import yaml
17
+
18
+ from ..exceptions import FileError, PathSecurityError, YAMLParseError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class AsyncFileManager:
24
+ """Safe async file operations restricted to a *config_path* directory."""
25
+
26
+ def __init__(self, config_path: Path) -> None:
27
+ self.config_path = config_path.resolve()
28
+
29
+ # ------------------------------------------------------------------
30
+ # Path helpers
31
+ # ------------------------------------------------------------------
32
+
33
+ def _get_full_path(self, relative_path: str) -> Path:
34
+ """Return the absolute path, ensuring it stays within *config_path*.
35
+
36
+ Raises :class:`PathSecurityError` if the resolved path escapes the
37
+ allowed directory tree.
38
+ """
39
+ if relative_path in ("", "/"):
40
+ return self.config_path
41
+
42
+ # Strip leading slash — treat as relative
43
+ if relative_path.startswith("/"):
44
+ relative_path = relative_path[1:]
45
+
46
+ full_path = (self.config_path / relative_path).resolve()
47
+
48
+ if not str(full_path).startswith(str(self.config_path)):
49
+ raise PathSecurityError(f"Path outside config directory: {relative_path}")
50
+
51
+ return full_path
52
+
53
+ # ------------------------------------------------------------------
54
+ # Public API
55
+ # ------------------------------------------------------------------
56
+
57
+ async def list_files(
58
+ self,
59
+ directory: str = "",
60
+ pattern: str = "*",
61
+ ) -> list[dict[str, object]]:
62
+ """List files in *directory* matching *pattern* (recursive glob)."""
63
+ try:
64
+ dir_path = self._get_full_path(directory)
65
+
66
+ if not dir_path.exists():
67
+ return []
68
+
69
+ files: list[dict[str, object]] = []
70
+ for item in dir_path.rglob(pattern):
71
+ if item.is_file():
72
+ rel_path = item.relative_to(self.config_path)
73
+ stat = item.stat()
74
+ 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
+ }
82
+ )
83
+
84
+ return sorted(files, key=lambda x: str(x["path"]))
85
+ except PathSecurityError:
86
+ raise
87
+ except Exception as exc:
88
+ logger.error("Error listing files: %s", exc)
89
+ raise FileError(str(exc)) from exc
90
+
91
+ async def read_file(self, file_path: str) -> str:
92
+ """Read and return the UTF-8 contents of *file_path*."""
93
+ try:
94
+ full_path = self._get_full_path(file_path)
95
+
96
+ if not full_path.exists():
97
+ raise FileNotFoundError(f"File not found: {file_path}")
98
+
99
+ async with aiofiles.open(full_path, encoding="utf-8") as fh:
100
+ content = await fh.read()
101
+
102
+ logger.info("Read file: %s (%d bytes)", file_path, len(content))
103
+ return content
104
+ except (FileNotFoundError, PathSecurityError):
105
+ raise
106
+ except Exception as exc:
107
+ logger.error("Error reading file %s: %s", file_path, exc)
108
+ raise FileError(str(exc)) from exc
109
+
110
+ async def write_file(self, file_path: str, content: str) -> dict[str, object]:
111
+ """Write *content* to *file_path*, creating parent directories as needed.
112
+
113
+ Returns a result dict with *success*, *path*, and *size*.
114
+
115
+ .. note::
116
+
117
+ This method does **not** interact with git. The integration layer
118
+ should call ``GitManager.commit_changes()`` afterwards if desired.
119
+ """
120
+ try:
121
+ full_path = self._get_full_path(file_path)
122
+ full_path.parent.mkdir(parents=True, exist_ok=True)
123
+
124
+ async with aiofiles.open(full_path, "w", encoding="utf-8") as fh:
125
+ await fh.write(content)
126
+
127
+ logger.info("Wrote file: %s (%d bytes)", file_path, len(content))
128
+
129
+ return {"success": True, "path": file_path, "size": len(content)}
130
+ except PathSecurityError:
131
+ raise
132
+ except Exception as exc:
133
+ logger.error("Error writing file %s: %s", file_path, exc)
134
+ raise FileError(str(exc)) from exc
135
+
136
+ async def append_file(self, file_path: str, content: str) -> dict[str, object]:
137
+ """Append *content* to *file_path*, creating it if it doesn't exist."""
138
+ try:
139
+ full_path = self._get_full_path(file_path)
140
+
141
+ if not full_path.exists():
142
+ full_path.parent.mkdir(parents=True, exist_ok=True)
143
+ full_path.touch()
144
+
145
+ async with aiofiles.open(full_path, encoding="utf-8") as fh:
146
+ existing = await fh.read()
147
+
148
+ new_content = (existing + "\n" + content) if existing else content
149
+
150
+ async with aiofiles.open(full_path, "w", encoding="utf-8") as fh:
151
+ await fh.write(new_content)
152
+
153
+ logger.info("Appended to file: %s (%d bytes)", file_path, len(content))
154
+
155
+ return {
156
+ "success": True,
157
+ "path": file_path,
158
+ "added_bytes": len(content),
159
+ "total_size": len(new_content),
160
+ }
161
+ except PathSecurityError:
162
+ raise
163
+ except Exception as exc:
164
+ logger.error("Error appending to file %s: %s", file_path, exc)
165
+ raise FileError(str(exc)) from exc
166
+
167
+ async def delete_file(self, file_path: str) -> dict[str, object]:
168
+ """Delete *file_path*.
169
+
170
+ Returns a result dict with *success* and *path*.
171
+ """
172
+ try:
173
+ full_path = self._get_full_path(file_path)
174
+
175
+ if not full_path.exists():
176
+ raise FileNotFoundError(f"File not found: {file_path}")
177
+
178
+ full_path.unlink()
179
+ logger.info("Deleted file: %s", file_path)
180
+
181
+ return {"success": True, "path": file_path}
182
+ except (FileNotFoundError, PathSecurityError):
183
+ raise
184
+ except Exception as exc:
185
+ logger.error("Error deleting file %s: %s", file_path, exc)
186
+ raise FileError(str(exc)) from exc
187
+
188
+ async def parse_yaml(self, file_path: str) -> dict[str, Any]:
189
+ """Parse a YAML file and return its contents as a dict."""
190
+ try:
191
+ content = await self.read_file(file_path)
192
+ data = yaml.safe_load(content)
193
+ return data or {}
194
+ except yaml.YAMLError as exc:
195
+ logger.error("YAML parse error in %s: %s", file_path, exc)
196
+ raise YAMLParseError(f"Invalid YAML: {exc}") from exc