Refactor addon git repo (#5987)

* Refactor Repository into setup with inheritance

* Remove subclasses of GitRepo
This commit is contained in:
Mike Degatano 2025-07-03 07:53:52 -04:00 committed by GitHub
parent 3e20a0937d
commit abc44946bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 183 additions and 139 deletions

View File

@ -42,13 +42,8 @@ class FixupStoreExecuteReset(FixupBase):
_LOGGER.warning("Can't find store %s for fixup", reference)
return
# Local add-ons are not a git repo, can't remove and re-pull
try:
if repository.git:
await repository.git.reset()
# Load data again
await repository.load()
await repository.reset()
except StoreError:
raise ResolutionFixupError() from None

View File

@ -135,7 +135,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
if url == URL_HASSIO_ADDONS:
url = StoreType.CORE
repository = Repository(self.coresys, url)
repository = Repository.create(self.coresys, url)
if repository.slug in self.repositories:
raise StoreError(f"Can't add {url}, already in the store", _LOGGER.error)
@ -183,7 +183,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
raise err
else:
if not await self.sys_run_in_executor(repository.validate):
if not await repository.validate():
if add_with_errors:
_LOGGER.error("%s is not a valid add-on repository", url)
self.sys_resolution.create_issue(

View File

@ -1,6 +1,5 @@
"""Init file for Supervisor add-on Git."""
from abc import ABC, abstractmethod
import asyncio
import errno
import functools as ft
@ -16,17 +15,14 @@ from ..exceptions import StoreGitCloneError, StoreGitError, StoreJobError
from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils import remove_folder
from .utils import get_hash_from_repository
from .validate import RE_REPOSITORY, BuiltinRepository
from .validate import RE_REPOSITORY
_LOGGER: logging.Logger = logging.getLogger(__name__)
class GitRepo(CoreSysAttributes, ABC):
class GitRepo(CoreSysAttributes):
"""Manage Add-on Git repository."""
builtin: bool
def __init__(self, coresys: CoreSys, path: Path, url: str):
"""Initialize Git base wrapper."""
self.coresys: CoreSys = coresys
@ -239,38 +235,8 @@ class GitRepo(CoreSysAttributes, ABC):
)
raise StoreGitError() from err
@abstractmethod
async def remove(self) -> None:
"""Remove a repository."""
class GitRepoBuiltin(GitRepo):
"""Built-in add-ons repository."""
builtin: bool = True
def __init__(self, coresys: CoreSys, repository: BuiltinRepository):
"""Initialize Git Supervisor add-on repository."""
super().__init__(coresys, repository.get_path(coresys), repository.url)
async def remove(self) -> None:
"""Raise. Cannot remove built-in repositories."""
raise RuntimeError("Cannot remove built-in repositories!")
class GitRepoCustom(GitRepo):
"""Custom add-ons repository."""
builtin: bool = False
def __init__(self, coresys, url):
"""Initialize custom Git Supervisor addo-n repository."""
path = Path(coresys.config.path_addons_git, get_hash_from_repository(url))
super().__init__(coresys, path, url)
async def remove(self) -> None:
"""Remove a custom repository."""
if self.lock.locked():
_LOGGER.warning(
"Cannot remove add-on repository %s, there is already a task in progress",

View File

@ -1,5 +1,8 @@
"""Represent a Supervisor repository."""
from __future__ import annotations
from abc import ABC, abstractmethod
import logging
from pathlib import Path
@ -12,7 +15,7 @@ from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError, StoreError
from ..utils.common import read_json_or_yaml_file
from .const import StoreType
from .git import GitRepo, GitRepoBuiltin, GitRepoCustom
from .git import GitRepo
from .utils import get_hash_from_repository
from .validate import SCHEMA_REPOSITORY_CONFIG, BuiltinRepository
@ -20,28 +23,24 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
UNKNOWN = "unknown"
class Repository(CoreSysAttributes):
class Repository(CoreSysAttributes, ABC):
"""Add-on store repository in Supervisor."""
def __init__(self, coresys: CoreSys, repository: str):
"""Initialize add-on store repository object."""
self._slug: str
self._type: StoreType
self.coresys: CoreSys = coresys
self.git: GitRepo | None = None
self.source: str = repository
@staticmethod
def create(coresys: CoreSys, repository: str) -> Repository:
"""Create a repository instance."""
if repository == StoreType.LOCAL:
self._slug = repository
self._type = StoreType.LOCAL
self._latest_mtime: float | None = None
elif repository in BuiltinRepository:
builtin = BuiltinRepository(repository)
self.git = GitRepoBuiltin(coresys, builtin)
self._slug = builtin.id
self._type = builtin.type
else:
self.git = GitRepoCustom(coresys, repository)
self._slug = get_hash_from_repository(repository)
self._type = StoreType.GIT
return RepositoryLocal(coresys)
if repository in BuiltinRepository:
return RepositoryGitBuiltin(coresys, BuiltinRepository(repository))
return RepositoryCustom(coresys, repository)
def __repr__(self) -> str:
"""Return internal representation."""
@ -77,52 +76,117 @@ class Repository(CoreSysAttributes):
"""Return url of repository."""
return self.data.get(ATTR_MAINTAINER, UNKNOWN)
def validate(self) -> bool:
"""Check if store is valid.
@abstractmethod
async def validate(self) -> bool:
"""Check if store is valid."""
Must be run in executor.
@abstractmethod
async def load(self) -> None:
"""Load addon repository."""
@abstractmethod
async def update(self) -> bool:
"""Update add-on repository.
Returns True if the repository was updated.
"""
if not self.git or self.type == StoreType.CORE:
return True
# If exists?
for filetype in FILE_SUFFIX_CONFIGURATION:
repository_file = Path(self.git.path / f"repository{filetype}")
if repository_file.exists():
break
@abstractmethod
async def remove(self) -> None:
"""Remove add-on repository."""
if not repository_file.exists():
return False
@abstractmethod
async def reset(self) -> None:
"""Reset add-on repository to fix corruption issue with files."""
# If valid?
try:
SCHEMA_REPOSITORY_CONFIG(read_json_or_yaml_file(repository_file))
except (ConfigurationFileError, vol.Invalid) as err:
_LOGGER.warning("Could not validate repository configuration %s", err)
return False
class RepositoryBuiltin(Repository, ABC):
"""A built-in add-on repository."""
def __init__(self, coresys: CoreSys, builtin: BuiltinRepository) -> None:
"""Initialize object."""
super().__init__(coresys, builtin.value)
self._builtin = builtin
self._slug = builtin.id
self._type = builtin.type
async def validate(self) -> bool:
"""Assume built-in repositories are always valid."""
return True
async def remove(self) -> None:
"""Raise. Not supported for built-in repositories."""
raise StoreError("Can't remove built-in repositories!", _LOGGER.error)
class RepositoryGit(Repository, ABC):
"""A git based add-on repository."""
_git: GitRepo
async def load(self) -> None:
"""Load addon repository."""
if not self.git:
self._latest_mtime, _ = await self.sys_run_in_executor(
get_latest_mtime, self.sys_config.path_addons_local
)
return
await self.git.load()
await self._git.load()
async def update(self) -> bool:
"""Update add-on repository.
Returns True if the repository was updated.
"""
if not await self.sys_run_in_executor(self.validate):
if not await self.validate():
return False
if self.git:
return await self.git.pull()
return await self._git.pull()
async def validate(self) -> bool:
"""Check if store is valid."""
def validate_file() -> bool:
# If exists?
for filetype in FILE_SUFFIX_CONFIGURATION:
repository_file = Path(self._git.path / f"repository{filetype}")
if repository_file.exists():
break
if not repository_file.exists():
return False
# If valid?
try:
SCHEMA_REPOSITORY_CONFIG(read_json_or_yaml_file(repository_file))
except (ConfigurationFileError, vol.Invalid) as err:
_LOGGER.warning("Could not validate repository configuration %s", err)
return False
return True
return await self.sys_run_in_executor(validate_file)
async def reset(self) -> None:
"""Reset add-on repository to fix corruption issue with files."""
await self._git.reset()
await self.load()
class RepositoryLocal(RepositoryBuiltin):
"""A local add-on repository."""
def __init__(self, coresys: CoreSys) -> None:
"""Initialize object."""
super().__init__(coresys, BuiltinRepository.LOCAL)
self._latest_mtime: float | None = None
async def load(self) -> None:
"""Load addon repository."""
self._latest_mtime, _ = await self.sys_run_in_executor(
get_latest_mtime, self.sys_config.path_addons_local
)
async def update(self) -> bool:
"""Update add-on repository.
Returns True if the repository was updated.
"""
# Check local modifications
latest_mtime, modified_path = await self.sys_run_in_executor(
get_latest_mtime, self.sys_config.path_addons_local
@ -138,9 +202,32 @@ class Repository(CoreSysAttributes):
return False
async def reset(self) -> None:
"""Raise. Not supported for local repository."""
raise StoreError(
"Can't reset local repository as it is not git based!", _LOGGER.error
)
class RepositoryGitBuiltin(RepositoryBuiltin, RepositoryGit):
"""A built-in add-on repository based on git."""
def __init__(self, coresys: CoreSys, builtin: BuiltinRepository) -> None:
"""Initialize object."""
super().__init__(coresys, builtin)
self._git = GitRepo(coresys, builtin.get_path(coresys), builtin.url)
class RepositoryCustom(RepositoryGit):
"""A custom add-on repository."""
def __init__(self, coresys: CoreSys, url: str) -> None:
"""Initialize object."""
super().__init__(coresys, url)
self._slug = get_hash_from_repository(url)
self._type = StoreType.GIT
self._git = GitRepo(coresys, coresys.config.path_addons_git / self._slug, url)
async def remove(self) -> None:
"""Remove add-on repository."""
if not self.git or self.git.builtin:
raise StoreError("Can't remove built-in repositories!", _LOGGER.error)
await self.git.remove()
await self._git.remove()

View File

@ -820,7 +820,7 @@ async def test_paths_cache(coresys: CoreSys, install_addon_ssh: Addon):
with (
patch("supervisor.addons.addon.Path.exists", return_value=True),
patch("supervisor.store.repository.Repository.update", return_value=True),
patch("supervisor.store.repository.RepositoryLocal.update", return_value=True),
):
await coresys.store.reload(coresys.store.get("local"))

View File

@ -29,7 +29,7 @@ from supervisor.plugins.dns import PluginDns
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository
from supervisor.store.repository import RepositoryLocal
from supervisor.utils import check_exception_chain
from supervisor.utils.common import write_json_file
@ -442,7 +442,7 @@ async def test_store_data_changes_during_update(
update_task = coresys.create_task(simulate_update())
await asyncio.sleep(0)
with patch.object(Repository, "update", return_value=True):
with patch.object(RepositoryLocal, "update", return_value=True):
await coresys.store.reload()
assert "image" not in coresys.store.data.addons["local_ssh"]

View File

@ -97,8 +97,8 @@ async def test_api_store_add_repository(
) -> None:
"""Test POST /store/repositories REST API."""
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.Repository.validate", return_value=True),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
):
response = await api_client.post(
"/store/repositories", json={"repository": REPO_URL}

View File

@ -42,8 +42,8 @@ async def test_api_supervisor_options_add_repository(
coresys.store.get_from_url(REPO_URL)
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.Repository.validate", return_value=True),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
):
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
@ -76,9 +76,9 @@ async def test_api_supervisor_options_repositories_skipped_on_error(
):
"""Test repositories skipped on error via POST /supervisor/options REST API."""
with (
patch("supervisor.store.repository.Repository.load", side_effect=git_error),
patch("supervisor.store.repository.Repository.validate", return_value=False),
patch("supervisor.store.repository.Repository.remove"),
patch("supervisor.store.repository.RepositoryGit.load", side_effect=git_error),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=False),
patch("supervisor.store.repository.RepositoryCustom.remove"),
):
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
@ -98,7 +98,7 @@ async def test_api_supervisor_options_repo_error_with_config_change(
assert not coresys.config.debug
with patch(
"supervisor.store.repository.Repository.load", side_effect=StoreGitError()
"supervisor.store.repository.RepositoryGit.load", side_effect=StoreGitError()
):
response = await api_client.post(
"/supervisor/options",

View File

@ -409,7 +409,7 @@ async def coresys(
coresys_obj.init_websession = AsyncMock()
# Don't remove files/folders related to addons and stores
with patch("supervisor.store.git.GitRepoCustom.remove"):
with patch("supervisor.store.git.GitRepo.remove"):
yield coresys_obj
await coresys_obj.dbus.unload()
@ -611,7 +611,7 @@ async def repository(coresys: CoreSys):
):
await coresys.store.load()
repository_obj = Repository(
repository_obj = Repository.create(
coresys, "https://github.com/awesome-developer/awesome-repo"
)

View File

@ -67,7 +67,7 @@ async def test_fixup(coresys: CoreSys):
path = path or obj.path
await coresys.run_in_executor((path / ".git").mkdir)
coresys.store.repositories["94cfad5a"] = Repository(
coresys.store.repositories["94cfad5a"] = Repository.create(
coresys, "https://github.com/home-assistant/addons-example"
)
with (
@ -97,7 +97,7 @@ async def test_fixup_clone_fail(coresys: CoreSys):
assert test_repo.exists()
assert corrupt_marker.exists()
coresys.store.repositories["94cfad5a"] = Repository(
coresys.store.repositories["94cfad5a"] = Repository.create(
coresys, "https://github.com/home-assistant/addons-example"
)
with (
@ -129,7 +129,7 @@ async def test_fixup_move_fail(coresys: CoreSys, error_num: int, unhealthy: bool
add_store_reset_suggestion(coresys)
test_repo.mkdir(parents=True)
coresys.store.repositories["94cfad5a"] = Repository(
coresys.store.repositories["94cfad5a"] = Repository.create(
coresys, "https://github.com/home-assistant/addons-example"
)
with (

View File

@ -33,7 +33,7 @@ async def test_add_valid_repository(
"""Test add custom repository."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch(
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
@ -54,7 +54,7 @@ async def test_add_invalid_repository(coresys: CoreSys, store_manager: StoreMana
"""Test add invalid custom repository."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch(
"pathlib.Path.read_text",
return_value="",
@ -64,9 +64,7 @@ async def test_add_invalid_repository(coresys: CoreSys, store_manager: StoreMana
current + ["http://example.com"], add_with_errors=True
)
assert not await coresys.run_in_executor(
store_manager.get_from_url("http://example.com").validate
)
assert not await store_manager.get_from_url("http://example.com").validate()
assert "http://example.com" in coresys.store.repository_urls
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -79,7 +77,7 @@ async def test_error_on_invalid_repository(
"""Test invalid repository not added."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch(
"pathlib.Path.read_text",
return_value="",
@ -103,7 +101,7 @@ async def test_add_invalid_repository_file(
"""Test add invalid custom repository file."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch(
"pathlib.Path.read_text",
return_value=json.dumps({"name": "Awesome repository"}),
@ -114,7 +112,7 @@ async def test_add_invalid_repository_file(
current + ["http://example.com"], add_with_errors=True
)
assert not store_manager.get_from_url("http://example.com").validate()
assert not await store_manager.get_from_url("http://example.com").validate()
assert "http://example.com" in coresys.store.repository_urls
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -135,7 +133,7 @@ async def test_add_repository_with_git_error(
):
"""Test repo added with issue on git error."""
current = coresys.store.repository_urls
with patch("supervisor.store.repository.Repository.load", side_effect=git_error):
with patch("supervisor.store.repository.RepositoryGit.load", side_effect=git_error):
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
)
@ -163,7 +161,7 @@ async def test_error_on_repository_with_git_error(
"""Test repo not added on git error."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.Repository.load", side_effect=git_error),
patch("supervisor.store.repository.RepositoryGit.load", side_effect=git_error),
pytest.raises(StoreError),
):
if use_update:
@ -182,7 +180,7 @@ async def test_preinstall_valid_repository(
coresys: CoreSys, store_manager: StoreManager
):
"""Test add core repository valid."""
with patch("supervisor.store.repository.Repository.load", return_value=None):
with patch("supervisor.store.repository.RepositoryGit.load", return_value=None):
await store_manager.update_repositories(BUILTIN_REPOSITORIES)
def validate():
@ -244,8 +242,8 @@ async def test_remove_used_repository(
async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManager):
"""Test partial error on update does partial save and errors."""
with patch("supervisor.store.repository.Repository.validate", return_value=True):
with patch("supervisor.store.repository.Repository.load", return_value=None):
with patch("supervisor.store.repository.RepositoryGit.validate", return_value=True):
with patch("supervisor.store.repository.RepositoryGit.load", return_value=None):
await store_manager.update_repositories([])
store_manager.data.update.assert_called_once()
@ -256,7 +254,7 @@ async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManage
with (
patch(
"supervisor.store.repository.Repository.load",
"supervisor.store.repository.RepositoryGit.load",
side_effect=[None, StoreGitError()],
),
pytest.raises(StoreError),
@ -275,8 +273,8 @@ async def test_error_adding_duplicate(
"""Test adding a duplicate repository causes an error."""
assert repository.source in coresys.store.repository_urls
with (
patch("supervisor.store.repository.Repository.validate", return_value=True),
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
pytest.raises(StoreError),
):
await store_manager.add_repository(repository.source)
@ -290,7 +288,7 @@ async def test_add_with_update_repositories(
assert "http://example.com" not in coresys.store.repository_urls
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch(
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
@ -328,7 +326,7 @@ async def test_repositories_loaded_ignore_updates(
):
"""Test repositories loaded whether or not supervisor needs an update."""
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch.object(
type(coresys.supervisor),
"need_update",

View File

@ -15,13 +15,6 @@ from supervisor.store.git import GitRepo
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
class GitRepoTest(GitRepo):
"""Implementation of GitRepo for tests that allows direct setting of path."""
async def remove(self) -> None:
"""Not implemented."""
@pytest.fixture(name="clone_from")
async def fixture_clone_from():
"""Mock git clone_from."""
@ -35,7 +28,7 @@ async def test_git_clone(
):
"""Test git clone."""
fragment = f"#{branch}" if branch else ""
repo = GitRepoTest(coresys, tmp_path, f"{REPO_URL}{fragment}")
repo = GitRepo(coresys, tmp_path, f"{REPO_URL}{fragment}")
await repo.clone.__wrapped__(repo)
@ -63,7 +56,7 @@ async def test_git_clone_error(
coresys: CoreSys, tmp_path: Path, clone_from: AsyncMock, git_error: Exception
):
"""Test git clone error."""
repo = GitRepoTest(coresys, tmp_path, REPO_URL)
repo = GitRepo(coresys, tmp_path, REPO_URL)
clone_from.side_effect = git_error
with pytest.raises(StoreGitCloneError):
@ -75,7 +68,7 @@ async def test_git_clone_error(
async def test_git_load(coresys: CoreSys, tmp_path: Path):
"""Test git load."""
repo_dir = tmp_path / "repo"
repo = GitRepoTest(coresys, repo_dir, REPO_URL)
repo = GitRepo(coresys, repo_dir, REPO_URL)
repo.clone = AsyncMock()
# Test with non-existing git repo root directory
@ -113,7 +106,7 @@ async def test_git_load(coresys: CoreSys, tmp_path: Path):
async def test_git_load_error(coresys: CoreSys, tmp_path: Path, git_errors: Exception):
"""Test git load error."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
repo = GitRepoTest(coresys, tmp_path, REPO_URL)
repo = GitRepo(coresys, tmp_path, REPO_URL)
# Pretend we have a repo
(tmp_path / ".git").mkdir()

View File

@ -32,7 +32,8 @@ async def test_default_load(coresys: CoreSys):
refresh_cache_calls.add(obj.slug)
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.repository.RepositoryLocal.load", return_value=None),
patch.object(type(coresys.config), "addons_repositories", return_value=[]),
patch("pathlib.Path.exists", return_value=True),
patch.object(AddonStore, "refresh_path_cache", new=mock_refresh_cache),
@ -80,9 +81,13 @@ async def test_load_with_custom_repository(coresys: CoreSys):
store_manager = await StoreManager(coresys).load_config()
with (
patch("supervisor.store.repository.Repository.load", return_value=None),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.repository.RepositoryLocal.load", return_value=None),
patch.object(type(coresys.config), "addons_repositories", return_value=[]),
patch("supervisor.store.repository.Repository.validate", return_value=True),
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
patch(
"supervisor.store.repository.RepositoryLocal.validate", return_value=True
),
patch("pathlib.Path.exists", return_value=True),
patch.object(AddonStore, "refresh_path_cache", new=mock_refresh_cache),
):