aiocortex 0.1.0__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.
aiocortex/__init__.py ADDED
@@ -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
+ ]
aiocortex/_version.py ADDED
@@ -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
@@ -0,0 +1,52 @@
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 re
9
+
10
+
11
+ class YAMLEditor:
12
+ """Utility for editing YAML files while preserving structure."""
13
+
14
+ @staticmethod
15
+ def remove_lines_from_end(content: str, num_lines: int) -> str:
16
+ """Remove *num_lines* from the end of *content*."""
17
+ lines = content.rstrip().split("\n")
18
+ if num_lines >= len(lines):
19
+ return ""
20
+ return "\n".join(lines[:-num_lines]) + "\n"
21
+
22
+ @staticmethod
23
+ def remove_empty_yaml_section(content: str, section_name: str) -> str:
24
+ """Remove an empty YAML section (e.g. ``lovelace:`` with only empty sub-keys)."""
25
+ # Pattern: comment + section with only empty subsections
26
+ pattern = rf"\n# .*{section_name.title()}.*\n{section_name}:\s*\n\s+\w+:\s*\n(?=\S|\Z)"
27
+ content = re.sub(pattern, "\n", content, flags=re.IGNORECASE)
28
+
29
+ # Also try without a preceding comment
30
+ pattern = rf"\n{section_name}:\s*\n\s+\w+:\s*\n(?=\S|\Z)"
31
+ content = re.sub(pattern, "\n", content, flags=re.IGNORECASE)
32
+
33
+ return content
34
+
35
+ @staticmethod
36
+ def remove_yaml_entry(
37
+ content: str,
38
+ section: str,
39
+ key: str,
40
+ ) -> tuple[str, bool]:
41
+ """Remove a specific entry from a YAML section.
42
+
43
+ Returns ``(modified_content, was_found)``.
44
+ """
45
+ pattern = rf" {re.escape(key)}:\s*\n(?: .*\n)*"
46
+
47
+ if re.search(pattern, content):
48
+ modified = re.sub(pattern, "", content)
49
+ modified = YAMLEditor.remove_empty_yaml_section(modified, section)
50
+ return modified, True
51
+
52
+ return content, False
@@ -0,0 +1,12 @@
1
+ """Git versioning with dulwich (no git binary required)."""
2
+
3
+ from .filters import should_include_path
4
+ from .manager import GitManager
5
+ from .sync import sync_config_to_shadow, sync_shadow_to_config
6
+
7
+ __all__ = [
8
+ "GitManager",
9
+ "should_include_path",
10
+ "sync_config_to_shadow",
11
+ "sync_shadow_to_config",
12
+ ]
@@ -0,0 +1,200 @@
1
+ """History truncation for the shadow git repository.
2
+
3
+ Uses dulwich for commit-chain rewriting where possible. Falls back to
4
+ ``git clone --depth`` via subprocess if the ``git`` binary is available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from dulwich.repo import Repo
17
+
18
+ from ..exceptions import GitError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _git_binary_available() -> bool:
24
+ """Return ``True`` if the ``git`` CLI is on ``$PATH``."""
25
+ try:
26
+ result = subprocess.run(
27
+ ["git", "--version"],
28
+ capture_output=True,
29
+ timeout=5,
30
+ )
31
+ return result.returncode == 0
32
+ except (FileNotFoundError, subprocess.TimeoutExpired):
33
+ return False
34
+
35
+
36
+ def truncate_history(
37
+ repo_path: Path,
38
+ commits_to_keep: int,
39
+ ) -> int:
40
+ """Truncate the repository at *repo_path* to at most *commits_to_keep* commits.
41
+
42
+ Returns the number of commits after truncation.
43
+
44
+ The strategy is:
45
+
46
+ 1. If the ``git`` binary is available, use ``git clone --depth`` into a
47
+ temp dir, then swap ``.git`` directories. This is the most reliable
48
+ approach on HA OS (where git is available in the container).
49
+ 2. Otherwise raise :class:`GitError` — pure-dulwich history rewriting is
50
+ a future enhancement.
51
+ """
52
+ repo_path = repo_path.resolve()
53
+ git_dir = repo_path / ".git"
54
+
55
+ if not git_dir.is_dir():
56
+ raise GitError(f"No .git directory at {repo_path}")
57
+
58
+ # --- Strategy 1: git clone --depth ---
59
+ if _git_binary_available():
60
+ return _truncate_via_clone(repo_path, commits_to_keep)
61
+
62
+ # --- Strategy 2: dulwich-native (basic orphan graft) ---
63
+ return _truncate_via_dulwich(repo_path, commits_to_keep)
64
+
65
+
66
+ def _truncate_via_clone(repo_path: Path, commits_to_keep: int) -> int:
67
+ """Clone with ``--depth`` and swap ``.git`` directories."""
68
+ git_dir = repo_path / ".git"
69
+
70
+ # Detect current branch
71
+ try:
72
+ result = subprocess.run(
73
+ ["git", "branch", "--show-current"],
74
+ cwd=str(repo_path),
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=10,
78
+ )
79
+ branch = result.stdout.strip() or "master"
80
+ except Exception:
81
+ branch = "master"
82
+
83
+ with tempfile.TemporaryDirectory() as tmpdir:
84
+ clone_path = os.path.join(tmpdir, "cloned_repo")
85
+ repo_url = f"file://{repo_path}"
86
+
87
+ logger.info("Cloning repository with depth=%d from %s ...", commits_to_keep, repo_url)
88
+ result = subprocess.run(
89
+ [
90
+ "git",
91
+ "clone",
92
+ "--depth",
93
+ str(commits_to_keep),
94
+ "--branch",
95
+ branch,
96
+ "--single-branch",
97
+ repo_url,
98
+ clone_path,
99
+ ],
100
+ capture_output=True,
101
+ text=True,
102
+ timeout=600,
103
+ )
104
+ if result.returncode != 0:
105
+ raise GitError(f"git clone failed: {result.stderr}")
106
+
107
+ cloned_git_dir = os.path.join(clone_path, ".git")
108
+ if not os.path.isdir(cloned_git_dir):
109
+ raise GitError("Cloned .git directory does not exist")
110
+
111
+ # Swap .git
112
+ backup_git = os.path.join(tmpdir, "git_backup")
113
+ shutil.copytree(str(git_dir), backup_git)
114
+ shutil.rmtree(str(git_dir))
115
+ shutil.copytree(cloned_git_dir, str(git_dir))
116
+
117
+ logger.info("Replaced .git directory with shallow clone")
118
+
119
+ # Optional gc
120
+ try:
121
+ subprocess.run(
122
+ ["git", "gc", "--prune=now", "--quiet"],
123
+ cwd=str(repo_path),
124
+ capture_output=True,
125
+ timeout=600,
126
+ )
127
+ except Exception as exc:
128
+ logger.warning("git gc after truncation failed: %s", exc)
129
+
130
+ # Return final count
131
+ try:
132
+ result = subprocess.run(
133
+ ["git", "rev-list", "--count", "--first-parent", "HEAD"],
134
+ cwd=str(repo_path),
135
+ capture_output=True,
136
+ text=True,
137
+ timeout=10,
138
+ )
139
+ return int(result.stdout.strip())
140
+ except Exception:
141
+ return commits_to_keep
142
+
143
+
144
+ def _truncate_via_dulwich(repo_path: Path, commits_to_keep: int) -> int:
145
+ """Basic dulwich-native truncation using orphan commit grafting.
146
+
147
+ Walks the commit chain, keeps *commits_to_keep* most recent commits,
148
+ and rewrites the oldest-kept commit to have no parents (orphan root).
149
+ Then packs and prunes unreachable objects.
150
+ """
151
+ repo = Repo(str(repo_path))
152
+
153
+ try:
154
+ head_sha = repo.head()
155
+ except KeyError:
156
+ raise GitError("Repository has no HEAD") from None
157
+
158
+ # Walk the first-parent chain
159
+ chain: list[bytes] = []
160
+ current = head_sha
161
+ while current and len(chain) < commits_to_keep + 1:
162
+ chain.append(current)
163
+ commit = repo.get_object(current)
164
+ current = commit.parents[0] if commit.parents else None
165
+
166
+ if len(chain) <= commits_to_keep:
167
+ # Nothing to truncate
168
+ return len(chain)
169
+
170
+ # Rewrite the oldest kept commit to have no parents
171
+ oldest_kept_sha = chain[commits_to_keep - 1]
172
+ oldest_kept = repo.get_object(oldest_kept_sha).copy()
173
+ oldest_kept.parents = []
174
+
175
+ # Store the rewritten commit
176
+ repo.object_store.add_object(oldest_kept)
177
+
178
+ # Rewrite the chain from oldest-kept to HEAD
179
+ sha_map: dict[bytes, bytes] = {oldest_kept_sha: oldest_kept.id}
180
+
181
+ for i in range(commits_to_keep - 2, -1, -1):
182
+ old_sha = chain[i]
183
+ commit = repo.get_object(old_sha).copy()
184
+ # Replace parent references
185
+ new_parents = []
186
+ for p in commit.parents:
187
+ new_parents.append(sha_map.get(p, p))
188
+ commit.parents = new_parents
189
+ repo.object_store.add_object(commit)
190
+ sha_map[old_sha] = commit.id
191
+
192
+ # Update HEAD/refs to point to new chain
193
+ new_head = sha_map.get(head_sha, head_sha)
194
+ for ref in repo.get_refs():
195
+ if repo.get_refs()[ref] == head_sha:
196
+ repo.refs[ref] = new_head
197
+
198
+ repo.close()
199
+
200
+ return commits_to_keep
@@ -0,0 +1,111 @@
1
+ """Path filtering — decide which files should be tracked in the shadow repo.
2
+
3
+ Ported from ``GitManager._should_include_path()`` in the HA Vibecode Agent
4
+ add-on. This module is pure logic with no git or filesystem side-effects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import os
11
+
12
+ # Directories excluded at the top level of /config
13
+ _EXCLUDED_DIRS: frozenset[str] = frozenset(
14
+ {
15
+ ".storage",
16
+ ".cloud",
17
+ ".homeassistant",
18
+ "www",
19
+ "media",
20
+ "storage",
21
+ "tmp",
22
+ "node_modules",
23
+ "__pycache__",
24
+ }
25
+ )
26
+
27
+ # Filename-level exclusion patterns
28
+ _SECRET_FILES: frozenset[str] = frozenset({"secrets.yaml", ".secrets.yaml"})
29
+ _SECRET_EXTS: tuple[str, ...] = ("*.pem", "*.key", "*.crt")
30
+ _DB_PATTERNS: tuple[str, ...] = (
31
+ "*.db",
32
+ "*.db-shm",
33
+ "*.db-wal",
34
+ "*.db-journal",
35
+ "*.sqlite",
36
+ "*.sqlite3",
37
+ "home-assistant_v2.db*",
38
+ )
39
+ _LOG_PATTERNS: tuple[str, ...] = ("*.log", "*.log.*", "home-assistant.log")
40
+ _BACKUP_PATTERNS: tuple[str, ...] = ("*.bak", "*.backup", "*.old", "*.tmp", "*.temp", "*~")
41
+ _DIR_PREFIXES: tuple[str, ...] = (
42
+ ".storage/",
43
+ ".cloud/",
44
+ ".homeassistant/",
45
+ "www/",
46
+ "media/",
47
+ "storage/",
48
+ "tmp/",
49
+ )
50
+
51
+
52
+ def should_include_path(
53
+ rel_path: str,
54
+ is_dir: bool,
55
+ *,
56
+ shadow_dir_name: str = "cortex_git",
57
+ ) -> bool:
58
+ """Return ``True`` if *rel_path* (relative to ``/config``) should be tracked.
59
+
60
+ Parameters
61
+ ----------
62
+ rel_path:
63
+ Forward-slash normalised path relative to the HA config directory.
64
+ is_dir:
65
+ Whether the path represents a directory.
66
+ shadow_dir_name:
67
+ Name of the shadow-repo directory to exclude (default ``cortex_git``).
68
+ """
69
+ rel_path = rel_path.replace(os.sep, "/")
70
+ parts = rel_path.split("/")
71
+
72
+ # Skip shadow repo and any .git directories
73
+ if parts[0] in (".git", shadow_dir_name):
74
+ return False
75
+
76
+ # Directory-level filtering
77
+ if is_dir:
78
+ return parts[0] not in _EXCLUDED_DIRS
79
+
80
+ filename = parts[-1]
81
+
82
+ # Secrets / keys
83
+ if filename in _SECRET_FILES:
84
+ return False
85
+ if any(fnmatch.fnmatch(filename, pat) for pat in _SECRET_EXTS):
86
+ return False
87
+
88
+ # DB files
89
+ if any(
90
+ fnmatch.fnmatch(filename, pat) or fnmatch.fnmatch(rel_path, pat) for pat in _DB_PATTERNS
91
+ ):
92
+ return False
93
+
94
+ # Logs
95
+ if any(
96
+ fnmatch.fnmatch(filename, pat) or fnmatch.fnmatch(rel_path, pat) for pat in _LOG_PATTERNS
97
+ ):
98
+ return False
99
+
100
+ # Backup-like files
101
+ if any(
102
+ fnmatch.fnmatch(filename, pat) or fnmatch.fnmatch(rel_path, pat)
103
+ for pat in _BACKUP_PATTERNS
104
+ ):
105
+ return False
106
+
107
+ # Files inside heavy/internal dirs (if they weren't pruned at dir level)
108
+ if any(rel_path.startswith(prefix) for prefix in _DIR_PREFIXES):
109
+ return False
110
+
111
+ return True