APIs for adding/removing an addon repository (#3649)

* APIs for adding/removing an addon repository

* Misunderstood addons.store, fixed usage
This commit is contained in:
Mike Degatano 2022-05-23 03:16:42 -04:00 committed by GitHub
parent 6e017a36c4
commit 138fd7eec9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 585 additions and 120 deletions

View File

@ -539,6 +539,10 @@ class RestAPI(CoreSysAttributes):
"/store/repositories/{repository}",
api_store.repositories_repository_info,
),
web.post("/store/repositories", api_store.add_repository),
web.delete(
"/store/repositories/{repository}", api_store.remove_repository
),
]
)

View File

@ -35,6 +35,7 @@ from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APIForbidden
from ..store.addon import AddonStore
from ..store.repository import Repository
from ..validate import validate_repository
SCHEMA_UPDATE = vol.Schema(
{
@ -42,6 +43,10 @@ SCHEMA_UPDATE = vol.Schema(
}
)
SCHEMA_ADD_REPOSITORY = vol.Schema(
{vol.Required(ATTR_REPOSITORY): vol.All(str, validate_repository)}
)
class APIStore(CoreSysAttributes):
"""Handle RESTful API for store functions."""
@ -173,3 +178,15 @@ class APIStore(CoreSysAttributes):
"""Return repository information."""
repository: Repository = self._extract_repository(request)
return self._generate_repository_information(repository)
@api_process
async def add_repository(self, request: web.Request):
"""Add repository to the store."""
body = await api_validate(SCHEMA_ADD_REPOSITORY, request)
await asyncio.shield(self.sys_store.add_repository(body[ATTR_REPOSITORY]))
@api_process
async def remove_repository(self, request: web.Request):
"""Remove repository from the store."""
repository: Repository = self._extract_repository(request)
await asyncio.shield(self.sys_store.remove_repository(repository))

View File

@ -6,8 +6,6 @@ from typing import Any, Awaitable
from aiohttp import web
import voluptuous as vol
from supervisor.resolution.const import ContextType, SuggestionType
from ..const import (
ATTR_ADDONS,
ATTR_ADDONS_REPOSITORIES,
@ -155,27 +153,15 @@ class APISupervisor(CoreSysAttributes):
if ATTR_FORCE_SECURITY in body:
self.sys_security.force = body[ATTR_FORCE_SECURITY]
if ATTR_ADDONS_REPOSITORIES in body:
new = set(body[ATTR_ADDONS_REPOSITORIES])
await asyncio.shield(self.sys_store.update_repositories(new))
# Fix invalid repository
found_invalid = False
for suggestion in self.sys_resolution.suggestions:
if (
suggestion.type != SuggestionType.EXECUTE_REMOVE
and suggestion.context != ContextType
):
continue
found_invalid = True
await self.sys_resolution.apply_suggestion(suggestion)
if found_invalid:
raise APIError("Invalid Add-on repository!")
# Save changes before processing addons in case of errors
self.sys_updater.save_data()
self.sys_config.save_data()
if ATTR_ADDONS_REPOSITORIES in body:
await asyncio.shield(
self.sys_store.update_repositories(set(body[ATTR_ADDONS_REPOSITORIES]))
)
await self.sys_resolution.evaluate.evaluate_system()
@api_process

View File

@ -508,7 +508,7 @@ class Backup(CoreSysAttributes):
if not replace:
new_list.update(self.sys_config.addons_repositories)
await self.sys_store.update_repositories(list(new_list))
await self.sys_store.update_repositories(list(new_list), add_with_errors=True)
def store_dockerconfig(self):
"""Store the configuration for Docker."""

View File

@ -454,6 +454,10 @@ class StoreGitError(StoreError):
"""Raise if something on git is happening."""
class StoreGitCloneError(StoreGitError):
"""Raise if error occurred while cloning repository."""
class StoreNotFound(StoreError):
"""Raise if slug is not known."""
@ -462,6 +466,10 @@ class StoreJobError(StoreError, JobException):
"""Raise on job error with git."""
class StoreInvalidAddonRepo(StoreError):
"""Raise on invalid addon repo."""
# Backup

View File

@ -56,4 +56,4 @@ class FixupStoreExecuteRemove(FixupBase):
@property
def auto(self) -> bool:
"""Return if a fixup can be apply as auto fix."""
return True
return False

View File

@ -4,7 +4,14 @@ import logging
from ..const import URL_HASSIO_ADDONS
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import StoreError, StoreGitError, StoreJobError, StoreNotFound
from ..exceptions import (
StoreError,
StoreGitCloneError,
StoreGitError,
StoreInvalidAddonRepo,
StoreJobError,
StoreNotFound,
)
from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType
from .addon import AddonStore
@ -53,7 +60,7 @@ class StoreManager(CoreSysAttributes):
repositories = set(self.sys_config.addons_repositories) | BUILTIN_REPOSITORIES
# Init custom repositories and load add-ons
await self.update_repositories(repositories)
await self.update_repositories(repositories, add_with_errors=True)
async def reload(self) -> None:
"""Update add-ons from repository and reload list."""
@ -66,26 +73,50 @@ class StoreManager(CoreSysAttributes):
self._read_addons()
@Job(conditions=[JobCondition.INTERNET_SYSTEM])
async def update_repositories(self, list_repositories):
"""Add a new custom repository."""
new_rep = set(list_repositories)
old_rep = {repository.source for repository in self.all}
async def add_repository(
self, url: str, *, persist: bool = True, add_with_errors: bool = False
):
"""Add a repository."""
if url == URL_HASSIO_ADDONS:
url = StoreType.CORE
# add new repository
async def _add_repository(url: str):
"""Add a repository."""
if url == URL_HASSIO_ADDONS:
url = StoreType.CORE
repository = Repository(self.coresys, url)
repository = Repository(self.coresys, url)
if repository.slug in self.repositories:
raise StoreError(f"Can't add {url}, already in the store", _LOGGER.error)
# Load the repository
try:
await repository.load()
except StoreGitError:
_LOGGER.error("Can't load data from repository %s", url)
except StoreJobError:
_LOGGER.warning("Skip update to later for %s", repository.slug)
# Load the repository
try:
await repository.load()
except StoreGitCloneError as err:
_LOGGER.error("Can't retrieve data from %s due to %s", url, err)
if add_with_errors:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=repository.slug,
suggestions=[SuggestionType.EXECUTE_REMOVE],
)
else:
await repository.remove()
raise err
except StoreGitError as err:
_LOGGER.error("Can't load data from repository %s due to %s", url, err)
if add_with_errors:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=repository.slug,
suggestions=[SuggestionType.EXECUTE_RESET],
)
else:
await repository.remove()
raise err
except StoreJobError as err:
_LOGGER.error("Can't add repository %s due to %s", url, err)
if add_with_errors:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
@ -93,7 +124,12 @@ class StoreManager(CoreSysAttributes):
suggestions=[SuggestionType.EXECUTE_RELOAD],
)
else:
if not repository.validate():
await repository.remove()
raise err
else:
if not repository.validate():
if add_with_errors:
_LOGGER.error("%s is not a valid add-on repository", url)
self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,
@ -101,34 +137,76 @@ class StoreManager(CoreSysAttributes):
reference=repository.slug,
suggestions=[SuggestionType.EXECUTE_REMOVE],
)
else:
await repository.remove()
raise StoreInvalidAddonRepo(
f"{url} is not a valid add-on repository", logger=_LOGGER.error
)
# Add Repository to list
if repository.type == StoreType.GIT:
self.sys_config.add_addon_repository(repository.source)
self.repositories[repository.slug] = repository
# Add Repository to list
if repository.type == StoreType.GIT:
self.sys_config.add_addon_repository(repository.source)
self.repositories[repository.slug] = repository
repos = new_rep - old_rep
tasks = [self.sys_create_task(_add_repository(url)) for url in repos]
if tasks:
await asyncio.wait(tasks)
# Persist changes
if persist:
await self.data.update()
self._read_addons()
async def remove_repository(self, repository: Repository, *, persist: bool = True):
"""Remove a repository."""
if repository.type != StoreType.GIT:
raise StoreInvalidAddonRepo(
"Can't remove built-in repositories!", logger=_LOGGER.error
)
if repository.slug in (addon.repository for addon in self.sys_addons.installed):
raise StoreError(
f"Can't remove '{repository.source}'. It's used by installed add-ons",
logger=_LOGGER.error,
)
await self.repositories.pop(repository.slug).remove()
self.sys_config.drop_addon_repository(repository.url)
if persist:
await self.data.update()
self._read_addons()
@Job(conditions=[JobCondition.INTERNET_SYSTEM])
async def update_repositories(
self, list_repositories, *, add_with_errors: bool = False
):
"""Add a new custom repository."""
new_rep = set(list_repositories)
old_rep = {repository.source for repository in self.all}
# Add new repositories
add_errors = await asyncio.gather(
*[
self.add_repository(url, persist=False, add_with_errors=add_with_errors)
for url in new_rep - old_rep
],
return_exceptions=True,
)
# Delete stale repositories
for url in old_rep - new_rep - BUILTIN_REPOSITORIES:
repository = self.get_from_url(url)
if repository.slug in (
addon.repository for addon in self.sys_addons.installed
):
raise StoreError(
f"Can't remove '{repository.source}'. It's used by installed add-ons",
logger=_LOGGER.error,
)
await self.repositories.pop(repository.slug).remove()
self.sys_config.drop_addon_repository(url)
remove_errors = await asyncio.gather(
*[
self.remove_repository(self.get_from_url(url), persist=False)
for url in old_rep - new_rep - BUILTIN_REPOSITORIES
],
return_exceptions=True,
)
# update data
# Always update data, even there are errors, some changes may have succeeded
await self.data.update()
self._read_addons()
# Raise the first error we found (if any)
for error in add_errors + remove_errors:
if error:
raise error
def _read_addons(self) -> None:
"""Reload add-ons inside store."""
all_addons = set(self.data.addons)

View File

@ -9,7 +9,7 @@ import git
from ..const import ATTR_BRANCH, ATTR_URL, URL_HASSIO_ADDONS
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import StoreGitError, StoreJobError
from ..exceptions import StoreGitCloneError, StoreGitError, StoreJobError
from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils import remove_folder
@ -65,12 +65,6 @@ class GitRepo(CoreSysAttributes):
git.GitCommandError,
) as err:
_LOGGER.error("Can't load %s", self.path)
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RESET],
)
raise StoreGitError() from err
# Fix possible corruption
@ -80,12 +74,6 @@ class GitRepo(CoreSysAttributes):
await self.sys_run_in_executor(self.repo.git.execute, ["git", "fsck"])
except git.GitCommandError as err:
_LOGGER.error("Integrity check on %s failed: %s.", self.path, err)
self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,
ContextType.STORE,
reference=self.path.stem,
suggestions=[SuggestionType.EXECUTE_RESET],
)
raise StoreGitError() from err
@Job(
@ -120,17 +108,7 @@ class GitRepo(CoreSysAttributes):
git.GitCommandError,
) as err:
_LOGGER.error("Can't clone %s repository: %s.", self.url, err)
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
reference=self.path.stem,
suggestions=[
SuggestionType.EXECUTE_RELOAD
if self.builtin
else SuggestionType.EXECUTE_REMOVE
],
)
raise StoreGitError() from err
raise StoreGitCloneError() from err
@Job(
conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_SYSTEM],

View File

@ -10,7 +10,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 GitRepoCustom, GitRepoHassIO
from .git import GitRepo, GitRepoCustom, GitRepoHassIO
from .utils import get_hash_from_repository
from .validate import SCHEMA_REPOSITORY_CONFIG
@ -24,7 +24,7 @@ class Repository(CoreSysAttributes):
def __init__(self, coresys: CoreSys, repository: str):
"""Initialize repository object."""
self.coresys: CoreSys = coresys
self.git: Optional[str] = None
self.git: Optional[GitRepo] = None
self.source: str = repository
if repository == StoreType.LOCAL:

View File

@ -1,10 +1,15 @@
"""Test Store API."""
from unittest.mock import patch
from aiohttp.test_utils import TestClient
import pytest
from supervisor.coresys import CoreSys
from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@pytest.mark.asyncio
async def test_api_store(
@ -65,3 +70,28 @@ async def test_api_store_repositories_repository(
result = await resp.json()
assert result["data"]["slug"] == repository.slug
async def test_api_store_add_repository(api_client: TestClient, coresys: CoreSys):
"""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
):
response = await api_client.post(
"/store/repositories", json={"repository": REPO_URL}
)
assert response.status == 200
assert REPO_URL in coresys.config.addons_repositories
assert isinstance(coresys.store.get_from_url(REPO_URL), Repository)
async def test_api_store_remove_repository(
api_client: TestClient, coresys: CoreSys, repository: Repository
):
"""Test DELETE /store/repositories/{repository} REST API."""
response = await api_client.delete(f"/store/repositories/{repository.slug}")
assert response.status == 200
assert repository.url not in coresys.config.addons_repositories
assert repository.slug not in coresys.store.repositories

View File

@ -1,15 +1,99 @@
"""Test Supervisor API."""
# pylint: disable=protected-access
from unittest.mock import patch
from aiohttp.test_utils import TestClient
import pytest
from supervisor.coresys import CoreSys
from supervisor.exceptions import StoreGitError, StoreNotFound
from supervisor.store.repository import Repository
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@pytest.mark.asyncio
async def test_api_supervisor_options_debug(api_client, coresys: CoreSys):
async def test_api_supervisor_options_debug(api_client: TestClient, coresys: CoreSys):
"""Test security options force security."""
assert not coresys.config.debug
await api_client.post("/supervisor/options", json={"debug": True})
assert coresys.config.debug
async def test_api_supervisor_options_add_repository(
api_client: TestClient, coresys: CoreSys
):
"""Test add a repository via POST /supervisor/options REST API."""
assert REPO_URL not in coresys.config.addons_repositories
with pytest.raises(StoreNotFound):
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
):
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
)
assert response.status == 200
assert REPO_URL in coresys.config.addons_repositories
assert isinstance(coresys.store.get_from_url(REPO_URL), Repository)
async def test_api_supervisor_options_remove_repository(
api_client: TestClient, coresys: CoreSys, repository: Repository
):
"""Test remove a repository via POST /supervisor/options REST API."""
assert repository.url in coresys.config.addons_repositories
assert repository.slug in coresys.store.repositories
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": []}
)
assert response.status == 200
assert repository.url not in coresys.config.addons_repositories
assert repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("git_error", [None, StoreGitError()])
async def test_api_supervisor_options_repositories_skipped_on_error(
api_client: TestClient, coresys: CoreSys, git_error: StoreGitError
):
"""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):
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
)
assert response.status == 400
assert len(coresys.resolution.suggestions) == 0
assert REPO_URL not in coresys.config.addons_repositories
with pytest.raises(StoreNotFound):
coresys.store.get_from_url(REPO_URL)
async def test_api_supervisor_options_repo_error_with_config_change(
api_client: TestClient, coresys: CoreSys
):
"""Test config change with add repository error via POST /supervisor/options REST API."""
assert not coresys.config.debug
with patch(
"supervisor.store.repository.Repository.load", side_effect=StoreGitError()
):
response = await api_client.post(
"/supervisor/options",
json={"debug": True, "addons_repositories": [REPO_URL]},
)
assert response.status == 400
assert REPO_URL not in coresys.config.addons_repositories
assert coresys.config.debug
coresys.updater.save_data.assert_called_once()
coresys.config.save_data.assert_called_once()

View File

@ -316,12 +316,18 @@ def store_addon(coresys: CoreSys, tmp_path, repository):
async def repository(coresys: CoreSys):
"""Repository fixture."""
coresys.config.drop_addon_repository("https://github.com/hassio-addons/repository")
coresys.config.drop_addon_repository(
"https://github.com/esphome/home-assistant-addon"
)
await coresys.store.load()
repository_obj = Repository(
coresys, "https://github.com/awesome-developer/awesome-repo"
)
coresys.store.repositories[repository_obj.slug] = repository_obj
coresys.config.add_addon_repository(
"https://github.com/awesome-developer/awesome-repo"
)
yield repository_obj

View File

@ -12,7 +12,7 @@ async def test_fixup(coresys: CoreSys):
"""Test fixup."""
store_execute_remove = FixupStoreExecuteRemove(coresys)
assert store_execute_remove.auto
assert store_execute_remove.auto is False
coresys.resolution.suggestions = Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference="test"

View File

@ -5,70 +5,178 @@ from unittest.mock import patch
import pytest
from supervisor.addons.addon import Addon
from supervisor.exceptions import StoreError
from supervisor.coresys import CoreSys
from supervisor.exceptions import (
StoreError,
StoreGitCloneError,
StoreGitError,
StoreNotFound,
)
from supervisor.resolution.const import SuggestionType
from supervisor.store import BUILTIN_REPOSITORIES
from supervisor.store import BUILTIN_REPOSITORIES, StoreManager
from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository
@pytest.mark.asyncio
async def test_add_valid_repository(coresys, store_manager):
@pytest.mark.parametrize("use_update", [True, False])
async def test_add_valid_repository(
coresys: CoreSys, store_manager: StoreManager, use_update: bool
):
"""Test add custom repository."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
), patch("pathlib.Path.exists", return_value=True):
if use_update:
await store_manager.update_repositories(current + ["http://example.com"])
else:
await store_manager.add_repository("http://example.com")
await store_manager.update_repositories(current + ["http://example.com"])
assert store_manager.get_from_url("http://example.com").validate()
assert "http://example.com" in coresys.config.addons_repositories
@pytest.mark.asyncio
async def test_add_valid_repository_url(coresys, store_manager):
"""Test add custom repository."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
), patch("pathlib.Path.exists", return_value=True):
await store_manager.update_repositories(current + ["http://example.com"])
assert store_manager.get_from_url("http://example.com").validate()
assert "http://example.com" in coresys.config.addons_repositories
@pytest.mark.asyncio
async def test_add_invalid_repository(coresys, store_manager):
"""Test add custom repository."""
@pytest.mark.parametrize("use_update", [True, False])
async def test_add_invalid_repository(
coresys: CoreSys, store_manager: StoreManager, use_update: bool
):
"""Test add invalid custom repository."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"pathlib.Path.read_text",
return_value="",
):
await store_manager.update_repositories(current + ["http://example.com"])
if use_update:
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
)
else:
await store_manager.add_repository(
"http://example.com", add_with_errors=True
)
assert not store_manager.get_from_url("http://example.com").validate()
assert "http://example.com" in coresys.config.addons_repositories
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@pytest.mark.asyncio
async def test_add_invalid_repository_file(coresys, store_manager):
"""Test add custom repository."""
@pytest.mark.parametrize("use_update", [True, False])
async def test_error_on_invalid_repository(
coresys: CoreSys, store_manager: StoreManager, use_update
):
"""Test invalid repository not added."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"pathlib.Path.read_text",
return_value="",
), pytest.raises(StoreError):
if use_update:
await store_manager.update_repositories(current + ["http://example.com"])
else:
await store_manager.add_repository("http://example.com")
assert "http://example.com" not in coresys.config.addons_repositories
assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com")
@pytest.mark.parametrize("use_update", [True, False])
async def test_add_invalid_repository_file(
coresys: CoreSys, store_manager: StoreManager, use_update: bool
):
"""Test add invalid custom repository file."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", return_value=None), patch(
"pathlib.Path.read_text",
return_value=json.dumps({"name": "Awesome repository"}),
), patch("pathlib.Path.exists", return_value=False):
await store_manager.update_repositories(current + ["http://example.com"])
if use_update:
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
)
else:
await store_manager.add_repository(
"http://example.com", add_with_errors=True
)
assert not store_manager.get_from_url("http://example.com").validate()
assert "http://example.com" in coresys.config.addons_repositories
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@pytest.mark.parametrize(
"use_update,git_error,suggestion_type",
[
(True, StoreGitCloneError(), SuggestionType.EXECUTE_REMOVE),
(True, StoreGitError(), SuggestionType.EXECUTE_RESET),
(False, StoreGitCloneError(), SuggestionType.EXECUTE_REMOVE),
(False, StoreGitError(), SuggestionType.EXECUTE_RESET),
],
)
async def test_add_repository_with_git_error(
coresys: CoreSys,
store_manager: StoreManager,
use_update: bool,
git_error: StoreGitError,
suggestion_type: SuggestionType,
):
"""Test repo added with issue on git error."""
current = coresys.config.addons_repositories
with patch("supervisor.store.repository.Repository.load", side_effect=git_error):
if use_update:
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
)
else:
await store_manager.add_repository(
"http://example.com", add_with_errors=True
)
assert "http://example.com" in coresys.config.addons_repositories
assert coresys.resolution.suggestions[-1].type == suggestion_type
assert isinstance(store_manager.get_from_url("http://example.com"), Repository)
@pytest.mark.parametrize(
"use_update,git_error",
[
(True, StoreGitCloneError()),
(True, StoreGitError()),
(False, StoreGitCloneError()),
(False, StoreGitError()),
],
)
async def test_error_on_repository_with_git_error(
coresys: CoreSys,
store_manager: StoreManager,
use_update: bool,
git_error: StoreGitError,
):
"""Test repo not added on git error."""
current = coresys.config.addons_repositories
with patch(
"supervisor.store.repository.Repository.load", side_effect=git_error
), pytest.raises(StoreError):
if use_update:
await store_manager.update_repositories(current + ["http://example.com"])
else:
await store_manager.add_repository("http://example.com")
assert "http://example.com" not in coresys.config.addons_repositories
assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com")
@pytest.mark.asyncio
async def test_preinstall_valid_repository(coresys, store_manager):
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):
await store_manager.update_repositories(BUILTIN_REPOSITORIES)
@ -76,15 +184,86 @@ async def test_preinstall_valid_repository(coresys, store_manager):
assert store_manager.get("local").validate()
@pytest.mark.asyncio
async def test_remove_used_repository(coresys, store_manager, store_addon):
@pytest.mark.parametrize("use_update", [True, False])
async def test_remove_repository(
coresys: CoreSys,
store_manager: StoreManager,
repository: Repository,
use_update: bool,
):
"""Test removing a custom repository."""
assert repository.url in coresys.config.addons_repositories
assert repository.slug in coresys.store.repositories
if use_update:
await store_manager.update_repositories([])
else:
await store_manager.remove_repository(repository)
assert repository.url not in coresys.config.addons_repositories
assert repository.slug not in coresys.addons.store
assert repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("use_update", [True, False])
async def test_remove_used_repository(
coresys: CoreSys,
store_manager: StoreManager,
store_addon: AddonStore,
use_update: bool,
):
"""Test removing used custom repository."""
coresys.addons.data.install(store_addon)
addon = Addon(coresys, store_addon.slug)
coresys.addons.local[addon.slug] = addon
assert store_addon.repository in coresys.store.repositories
with pytest.raises(
StoreError,
match="Can't remove 'https://github.com/awesome-developer/awesome-repo'. It's used by installed add-ons",
):
await store_manager.update_repositories([])
if use_update:
await store_manager.update_repositories([])
else:
await store_manager.remove_repository(
coresys.store.repositories[store_addon.repository]
)
async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManager):
"""Test partial error on update does partial save and errors."""
current = coresys.config.addons_repositories
initial = len(current)
with patch("supervisor.store.repository.Repository.validate", return_value=True):
with patch("supervisor.store.repository.Repository.load", return_value=None):
await store_manager.update_repositories(current)
store_manager.data.update.assert_called_once()
store_manager.data.update.reset_mock()
with patch(
"supervisor.store.repository.Repository.load",
side_effect=[None, StoreGitError()],
), pytest.raises(StoreError):
await store_manager.update_repositories(
current + ["http://example.com", "http://example2.com"]
)
assert len(coresys.config.addons_repositories) == initial + 1
store_manager.data.update.assert_called_once()
async def test_error_adding_duplicate(
coresys: CoreSys, store_manager: StoreManager, repository: Repository
):
"""Test adding a duplicate repository causes an error."""
assert repository.url in coresys.config.addons_repositories
with patch(
"supervisor.store.repository.Repository.validate", return_value=True
), patch(
"supervisor.store.repository.Repository.load", return_value=None
), pytest.raises(
StoreError
):
await store_manager.add_repository(repository.url)

View File

@ -0,0 +1,95 @@
"""Test git repository."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, patch
from git import GitCommandError, GitError, InvalidGitRepositoryError, NoSuchPathError
import pytest
from supervisor.coresys import CoreSys
from supervisor.exceptions import StoreGitCloneError, StoreGitError
from supervisor.store.git import GitRepo
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@pytest.fixture(name="clone_from")
async def fixture_clone_from():
"""Mock git clone_from."""
with patch("git.Repo.clone_from") as clone_from:
yield clone_from
@pytest.mark.parametrize("branch", [None, "dev"])
async def test_git_clone(
coresys: CoreSys, tmp_path: Path, clone_from: AsyncMock, branch: str | None
):
"""Test git clone."""
fragment = f"#{branch}" if branch else ""
repo = GitRepo(coresys, tmp_path, f"{REPO_URL}{fragment}")
await repo.clone.__wrapped__(repo)
kwargs = {"recursive": True, "depth": 1, "shallow-submodules": True}
if branch:
kwargs["branch"] = branch
clone_from.assert_called_once_with(
REPO_URL,
str(tmp_path),
**kwargs,
)
@pytest.mark.parametrize(
"git_error",
[InvalidGitRepositoryError(), NoSuchPathError(), GitCommandError("clone")],
)
async def test_git_clone_error(
coresys: CoreSys, tmp_path: Path, clone_from: AsyncMock, git_error: GitError
):
"""Test git clone error."""
repo = GitRepo(coresys, tmp_path, REPO_URL)
clone_from.side_effect = git_error
with pytest.raises(StoreGitCloneError):
await repo.clone.__wrapped__(repo)
assert len(coresys.resolution.suggestions) == 0
async def test_git_load(coresys: CoreSys, tmp_path: Path):
"""Test git load."""
repo = GitRepo(coresys, tmp_path, REPO_URL)
with patch("pathlib.Path.is_dir", return_value=True), patch.object(
GitRepo, "sys_run_in_executor", new_callable=AsyncMock
) as run_in_executor:
await repo.load()
assert run_in_executor.call_count == 2
@pytest.mark.parametrize(
"git_errors",
[
InvalidGitRepositoryError(),
NoSuchPathError(),
GitCommandError("init"),
[AsyncMock(), GitCommandError("fsck")],
],
)
async def test_git_load_error(
coresys: CoreSys, tmp_path: Path, git_errors: GitError | list[GitError | None]
):
"""Test git load error."""
repo = GitRepo(coresys, tmp_path, REPO_URL)
with patch("pathlib.Path.is_dir", return_value=True), patch.object(
GitRepo, "sys_run_in_executor", new_callable=AsyncMock
) as run_in_executor, pytest.raises(StoreGitError):
run_in_executor.side_effect = git_errors
await repo.load()
assert len(coresys.resolution.suggestions) == 0