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:
parent
6e017a36c4
commit
138fd7eec9
|
@ -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
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue