Refactoring around add-on store Repository classes (#5990)

* Rename repository fixture to test_repository

Also don't remove the built-in repositories. The list was incomplete,
and tests don't seem to require that anymore.

* Get rid of StoreType

The type doesn't have much value, we have constant strings anyways.

* Introduce types.py

* Use slug to determine which repository urls to return

* Simplify BuiltinRepository enum

* Mock GitRepo load

* Improve URL handling and repository creation logic

* Refactor update_repositories

* Get rid of get_from_url

It is no longer used in production code.

* More refactoring

* Address pylint

* Introduce is_git_based property to Repository class

Return all git based URLs, including the Core repository.

* Revert "Introduce is_git_based property to Repository class"

This reverts commit dfd5ad79bf23e0e127fc45d97d6f8de0e796faa0.

* Fold type.py into const.py

Align more with how Supervisor code is typically structured.

* Update supervisor/store/__init__.py

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Apply repository remove suggestion

* Fix tests

---------

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
This commit is contained in:
Stefan Agner 2025-07-10 11:07:53 +02:00 committed by GitHub
parent 7873c457d5
commit baf9695cf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 247 additions and 235 deletions

View File

@ -931,5 +931,5 @@ class Backup(JobGroup):
Return a coroutine.
"""
return self.sys_store.update_repositories(
self.repositories, add_with_errors=True, replace=replace
self.repositories, issue_on_error=True, replace=replace
)

View File

@ -4,7 +4,7 @@ import asyncio
from collections.abc import Awaitable
import logging
from ..const import ATTR_REPOSITORIES, URL_HASSIO_ADDONS
from ..const import ATTR_REPOSITORIES, REPOSITORY_CORE, URL_HASSIO_ADDONS
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import (
StoreError,
@ -18,14 +18,10 @@ from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils.common import FileConfiguration
from .addon import AddonStore
from .const import FILE_HASSIO_STORE, StoreType
from .const import FILE_HASSIO_STORE, BuiltinRepository
from .data import StoreData
from .repository import Repository
from .validate import (
BUILTIN_REPOSITORIES,
SCHEMA_STORE_FILE,
ensure_builtin_repositories,
)
from .validate import SCHEMA_STORE_FILE, ensure_builtin_repositories
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -56,7 +52,8 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
return [
repository.source
for repository in self.all
if repository.type == StoreType.GIT
if repository.slug
not in {BuiltinRepository.LOCAL.value, BuiltinRepository.CORE.value}
]
def get(self, slug: str) -> Repository:
@ -65,19 +62,11 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
raise StoreNotFound()
return self.repositories[slug]
def get_from_url(self, url: str) -> Repository:
"""Return Repository with slug."""
for repository in self.all:
if repository.source != url:
continue
return repository
raise StoreNotFound()
async def load(self) -> None:
"""Start up add-on management."""
# Init custom repositories and load add-ons
await self.update_repositories(
self._data[ATTR_REPOSITORIES], add_with_errors=True
self._data[ATTR_REPOSITORIES], issue_on_error=True
)
@Job(
@ -126,14 +115,14 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
)
async def add_repository(self, url: str, *, persist: bool = True) -> None:
"""Add a repository."""
await self._add_repository(url, persist=persist, add_with_errors=False)
await self._add_repository(url, persist=persist, issue_on_error=False)
async def _add_repository(
self, url: str, *, persist: bool = True, add_with_errors: bool = False
self, url: str, *, persist: bool = True, issue_on_error: bool = False
) -> None:
"""Add a repository."""
if url == URL_HASSIO_ADDONS:
url = StoreType.CORE
url = REPOSITORY_CORE
repository = Repository.create(self.coresys, url)
@ -145,7 +134,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
await repository.load()
except StoreGitCloneError as err:
_LOGGER.error("Can't retrieve data from %s due to %s", url, err)
if add_with_errors:
if issue_on_error:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
@ -158,7 +147,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
except StoreGitError as err:
_LOGGER.error("Can't load data from repository %s due to %s", url, err)
if add_with_errors:
if issue_on_error:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
@ -171,7 +160,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
except StoreJobError as err:
_LOGGER.error("Can't add repository %s due to %s", url, err)
if add_with_errors:
if issue_on_error:
self.sys_resolution.create_issue(
IssueType.FATAL_ERROR,
ContextType.STORE,
@ -184,7 +173,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
else:
if not await repository.validate():
if add_with_errors:
if issue_on_error:
_LOGGER.error("%s is not a valid add-on repository", url)
self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,
@ -213,7 +202,7 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
async def remove_repository(self, repository: Repository, *, persist: bool = True):
"""Remove a repository."""
if repository.source in BUILTIN_REPOSITORIES:
if repository.is_builtin:
raise StoreInvalidAddonRepo(
"Can't remove built-in repositories!", logger=_LOGGER.error
)
@ -236,38 +225,54 @@ class StoreManager(CoreSysAttributes, FileConfiguration):
self,
list_repositories: list[str],
*,
add_with_errors: bool = False,
issue_on_error: bool = False,
replace: bool = True,
):
"""Add a new custom repository."""
new_rep = set(
ensure_builtin_repositories(list_repositories)
if replace
else list_repositories + self.repository_urls
)
old_rep = {repository.source for repository in self.all}
"""Update repositories by adding new ones and removing stale ones."""
current_repositories = {repository.source for repository in self.all}
# Determine changes needed
if replace:
target_repositories = set(ensure_builtin_repositories(list_repositories))
repositories_to_add = target_repositories - current_repositories
else:
# When not replacing, just add the new repositories
repositories_to_add = set(list_repositories) - current_repositories
target_repositories = current_repositories | repositories_to_add
# Add new repositories
add_errors = await asyncio.gather(
*[
self._add_repository(url, persist=False, add_with_errors=True)
if add_with_errors
# Use _add_repository to avoid JobCondition.SUPERVISOR_UPDATED
# to prevent proper loading of repositories on startup.
self._add_repository(url, persist=False, issue_on_error=True)
if issue_on_error
else self.add_repository(url, persist=False)
for url in new_rep - old_rep
for url in repositories_to_add
],
return_exceptions=True,
)
# Delete stale repositories
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,
)
remove_errors: list[BaseException | None] = []
if replace:
# Determine repositories to remove
repositories_to_remove: list[Repository] = [
repository
for repository in self.all
if repository.source not in target_repositories
and not repository.is_builtin
]
# Always update data, even there are errors, some changes may have succeeded
# Remove repositories
remove_errors = await asyncio.gather(
*[
self.remove_repository(repository, persist=False)
for repository in repositories_to_remove
],
return_exceptions=True,
)
# Always update data, even if there are errors, some changes may have succeeded
await self.data.update()
await self._read_addons()

View File

@ -3,14 +3,39 @@
from enum import StrEnum
from pathlib import Path
from ..const import SUPERVISOR_DATA
from ..const import (
REPOSITORY_CORE,
REPOSITORY_LOCAL,
SUPERVISOR_DATA,
URL_HASSIO_ADDONS,
)
FILE_HASSIO_STORE = Path(SUPERVISOR_DATA, "store.json")
"""Repository type definitions for the store."""
class StoreType(StrEnum):
"""Store Types."""
class BuiltinRepository(StrEnum):
"""All built-in repositories that come pre-configured."""
CORE = "core"
LOCAL = "local"
GIT = "git"
# Local repository (non-git, special handling)
LOCAL = REPOSITORY_LOCAL
# Git-based built-in repositories
CORE = REPOSITORY_CORE
COMMUNITY_ADDONS = "https://github.com/hassio-addons/repository"
ESPHOME = "https://github.com/esphome/home-assistant-addon"
MUSIC_ASSISTANT = "https://github.com/music-assistant/home-assistant-addon"
@property
def git_url(self) -> str:
"""Return the git URL for this repository."""
if self == BuiltinRepository.LOCAL:
raise RuntimeError("Local repository does not have a git URL")
if self == BuiltinRepository.CORE:
return URL_HASSIO_ADDONS
else:
return self.value # For URL-based repos, value is the URL
# All repositories that are considered "built-in" and protected from removal
ALL_BUILTIN_REPOSITORIES = {repo.value for repo in BuiltinRepository}

View File

@ -25,7 +25,6 @@ from ..exceptions import ConfigurationFileError
from ..resolution.const import ContextType, IssueType, SuggestionType, UnhealthyReason
from ..utils.common import find_one_filetype, read_json_or_yaml_file
from ..utils.json import read_json_file
from .const import StoreType
from .utils import extract_hash_from_path
from .validate import SCHEMA_REPOSITORY_CONFIG
@ -169,7 +168,7 @@ class StoreData(CoreSysAttributes):
self.sys_resolution.add_unhealthy_reason(
UnhealthyReason.OSERROR_BAD_MESSAGE
)
elif path.stem != StoreType.LOCAL:
elif repository != REPOSITORY_LOCAL:
suggestion = [SuggestionType.EXECUTE_RESET]
self.sys_resolution.create_issue(
IssueType.CORRUPT_REPOSITORY,

View File

@ -10,14 +10,21 @@ import voluptuous as vol
from supervisor.utils import get_latest_mtime
from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_URL, FILE_SUFFIX_CONFIGURATION
from ..const import (
ATTR_MAINTAINER,
ATTR_NAME,
ATTR_URL,
FILE_SUFFIX_CONFIGURATION,
REPOSITORY_CORE,
REPOSITORY_LOCAL,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import ConfigurationFileError, StoreError
from ..utils.common import read_json_or_yaml_file
from .const import StoreType
from .const import BuiltinRepository
from .git import GitRepo
from .utils import get_hash_from_repository
from .validate import SCHEMA_REPOSITORY_CONFIG, BuiltinRepository
from .validate import SCHEMA_REPOSITORY_CONFIG
_LOGGER: logging.Logger = logging.getLogger(__name__)
UNKNOWN = "unknown"
@ -26,21 +33,45 @@ UNKNOWN = "unknown"
class Repository(CoreSysAttributes, ABC):
"""Add-on store repository in Supervisor."""
def __init__(self, coresys: CoreSys, repository: str):
def __init__(self, coresys: CoreSys, repository: str, local_path: Path, slug: str):
"""Initialize add-on store repository object."""
self._slug: str
self._type: StoreType
self._slug: str = slug
self._local_path: Path = local_path
self.coresys: CoreSys = coresys
self.source: str = repository
@staticmethod
def create(coresys: CoreSys, repository: str) -> Repository:
"""Create a repository instance."""
if repository == StoreType.LOCAL:
return RepositoryLocal(coresys)
if repository in BuiltinRepository:
return RepositoryGitBuiltin(coresys, BuiltinRepository(repository))
return RepositoryCustom(coresys, repository)
return Repository._create_builtin(coresys, BuiltinRepository(repository))
else:
return Repository._create_custom(coresys, repository)
@staticmethod
def _create_builtin(coresys: CoreSys, builtin: BuiltinRepository) -> Repository:
"""Create builtin repository."""
if builtin == BuiltinRepository.LOCAL:
slug = REPOSITORY_LOCAL
local_path = coresys.config.path_addons_local
return RepositoryLocal(coresys, local_path, slug)
elif builtin == BuiltinRepository.CORE:
slug = REPOSITORY_CORE
local_path = coresys.config.path_addons_core
else:
# For other builtin repositories (URL-based)
slug = get_hash_from_repository(builtin.value)
local_path = coresys.config.path_addons_git / slug
return RepositoryGitBuiltin(
coresys, builtin.value, local_path, slug, builtin.git_url
)
@staticmethod
def _create_custom(coresys: CoreSys, repository: str) -> RepositoryCustom:
"""Create custom repository."""
slug = get_hash_from_repository(repository)
local_path = coresys.config.path_addons_git / slug
return RepositoryCustom(coresys, repository, local_path, slug)
def __repr__(self) -> str:
"""Return internal representation."""
@ -52,9 +83,9 @@ class Repository(CoreSysAttributes, ABC):
return self._slug
@property
def type(self) -> StoreType:
"""Return type of the store."""
return self._type
def local_path(self) -> Path:
"""Return local path to repository."""
return self._local_path
@property
def data(self) -> dict:
@ -76,6 +107,11 @@ class Repository(CoreSysAttributes, ABC):
"""Return url of repository."""
return self.data.get(ATTR_MAINTAINER, UNKNOWN)
@property
@abstractmethod
def is_builtin(self) -> bool:
"""Return True if this is a built-in repository."""
@abstractmethod
async def validate(self) -> bool:
"""Check if store is valid."""
@ -103,12 +139,10 @@ class Repository(CoreSysAttributes, ABC):
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
@property
def is_builtin(self) -> bool:
"""Return True if this is a built-in repository."""
return True
async def validate(self) -> bool:
"""Assume built-in repositories are always valid."""
@ -171,15 +205,15 @@ class RepositoryGit(Repository, ABC):
class RepositoryLocal(RepositoryBuiltin):
"""A local add-on repository."""
def __init__(self, coresys: CoreSys) -> None:
def __init__(self, coresys: CoreSys, local_path: Path, slug: str) -> None:
"""Initialize object."""
super().__init__(coresys, BuiltinRepository.LOCAL)
super().__init__(coresys, BuiltinRepository.LOCAL.value, local_path, slug)
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
get_latest_mtime, self.local_path
)
async def update(self) -> bool:
@ -189,7 +223,7 @@ class RepositoryLocal(RepositoryBuiltin):
"""
# Check local modifications
latest_mtime, modified_path = await self.sys_run_in_executor(
get_latest_mtime, self.sys_config.path_addons_local
get_latest_mtime, self.local_path
)
if self._latest_mtime != latest_mtime:
_LOGGER.debug(
@ -212,21 +246,26 @@ class RepositoryLocal(RepositoryBuiltin):
class RepositoryGitBuiltin(RepositoryBuiltin, RepositoryGit):
"""A built-in add-on repository based on git."""
def __init__(self, coresys: CoreSys, builtin: BuiltinRepository) -> None:
def __init__(
self, coresys: CoreSys, repository: str, local_path: Path, slug: str, url: str
) -> None:
"""Initialize object."""
super().__init__(coresys, builtin)
self._git = GitRepo(coresys, builtin.get_path(coresys), builtin.url)
super().__init__(coresys, repository, local_path, slug)
self._git = GitRepo(coresys, local_path, url)
class RepositoryCustom(RepositoryGit):
"""A custom add-on repository."""
def __init__(self, coresys: CoreSys, url: str) -> None:
def __init__(self, coresys: CoreSys, url: str, local_path: Path, slug: 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)
super().__init__(coresys, url, local_path, slug)
self._git = GitRepo(coresys, local_path, url)
@property
def is_builtin(self) -> bool:
"""Return True if this is a built-in repository."""
return False
async def remove(self) -> None:
"""Remove add-on repository."""

View File

@ -1,62 +1,10 @@
"""Validate add-ons options schema."""
from enum import StrEnum
from pathlib import Path
import voluptuous as vol
from ..const import (
ATTR_MAINTAINER,
ATTR_NAME,
ATTR_REPOSITORIES,
ATTR_URL,
URL_HASSIO_ADDONS,
)
from ..coresys import CoreSys
from ..const import ATTR_MAINTAINER, ATTR_NAME, ATTR_REPOSITORIES, ATTR_URL
from ..validate import RE_REPOSITORY
from .const import StoreType
from .utils import get_hash_from_repository
URL_COMMUNITY_ADDONS = "https://github.com/hassio-addons/repository"
URL_ESPHOME = "https://github.com/esphome/home-assistant-addon"
URL_MUSIC_ASSISTANT = "https://github.com/music-assistant/home-assistant-addon"
class BuiltinRepository(StrEnum):
"""Built-in add-on repository."""
CORE = StoreType.CORE.value
LOCAL = StoreType.LOCAL.value
COMMUNITY_ADDONS = URL_COMMUNITY_ADDONS
ESPHOME = URL_ESPHOME
MUSIC_ASSISTANT = URL_MUSIC_ASSISTANT
def __init__(self, value: str) -> None:
"""Initialize repository item."""
if value == StoreType.LOCAL:
self.id = value
self.url = ""
self.type = StoreType.LOCAL
elif value == StoreType.CORE:
self.id = value
self.url = URL_HASSIO_ADDONS
self.type = StoreType.CORE
else:
self.id = get_hash_from_repository(value)
self.url = value
self.type = StoreType.GIT
def get_path(self, coresys: CoreSys) -> Path:
"""Get path to git repo for repository."""
if self.id == StoreType.LOCAL:
return coresys.config.path_addons_local
if self.id == StoreType.CORE:
return coresys.config.path_addons_core
return Path(coresys.config.path_addons_git, self.id)
BUILTIN_REPOSITORIES = {r.value for r in BuiltinRepository}
from .const import ALL_BUILTIN_REPOSITORIES, BuiltinRepository
# pylint: disable=no-value-for-parameter
SCHEMA_REPOSITORY_CONFIG = vol.Schema(
@ -75,12 +23,12 @@ def ensure_builtin_repositories(addon_repositories: list[str]) -> list[str]:
Note: This should not be used in validation as the resulting list is not
stable. This can have side effects when comparing data later on.
"""
return list(set(addon_repositories) | BUILTIN_REPOSITORIES)
return list(set(addon_repositories) | ALL_BUILTIN_REPOSITORIES)
def validate_repository(repository: str) -> str:
"""Validate a valid repository."""
if repository in [StoreType.CORE, StoreType.LOCAL]:
if repository in BuiltinRepository:
return repository
data = RE_REPOSITORY.match(repository)
@ -99,7 +47,7 @@ repositories = vol.All([validate_repository], vol.Unique())
SCHEMA_STORE_FILE = vol.Schema(
{
vol.Optional(
ATTR_REPOSITORIES, default=list(BUILTIN_REPOSITORIES)
ATTR_REPOSITORIES, default=list(ALL_BUILTIN_REPOSITORIES)
): repositories,
},
extra=vol.REMOVE_EXTRA,

View File

@ -209,7 +209,7 @@ async def test_watchdog_on_stop(coresys: CoreSys, install_addon_ssh: Addon) -> N
async def test_listener_attached_on_install(
coresys: CoreSys, mock_amd64_arch_supported: None, repository
coresys: CoreSys, mock_amd64_arch_supported: None, test_repository
):
"""Test events listener attached on addon install."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
@ -242,7 +242,7 @@ async def test_listener_attached_on_install(
)
async def test_watchdog_during_attach(
coresys: CoreSys,
repository: Repository,
test_repository: Repository,
boot_timedelta: timedelta,
restart_count: int,
):
@ -710,7 +710,7 @@ async def test_local_example_install(
coresys: CoreSys,
container: MagicMock,
tmp_supervisor_data: Path,
repository,
test_repository,
mock_aarch64_arch_supported: None,
):
"""Test install of an addon."""

View File

@ -67,7 +67,7 @@ async def fixture_remove_wait_boot(coresys: CoreSys) -> AsyncGenerator[None]:
@pytest.fixture(name="install_addon_example_image")
async def fixture_install_addon_example_image(
coresys: CoreSys, repository
coresys: CoreSys, test_repository
) -> Generator[Addon]:
"""Install local_example add-on with image."""
store = coresys.addons.store["local_example_image"]

View File

@ -54,7 +54,7 @@ async def test_addons_info(
# DEPRECATED - Remove with legacy routing logic on 1/2023
async def test_addons_info_not_installed(
api_client: TestClient, coresys: CoreSys, repository: Repository
api_client: TestClient, coresys: CoreSys, test_repository: Repository
):
"""Test getting addon info for not installed addon."""
resp = await api_client.get(f"/addons/{TEST_ADDON_SLUG}/info")
@ -533,7 +533,7 @@ async def test_addon_not_found(
("get", "/addons/local_ssh/logs/boots/1/follow", False),
],
)
@pytest.mark.usefixtures("repository")
@pytest.mark.usefixtures("test_repository")
async def test_addon_not_installed(
api_client: TestClient, method: str, url: str, json_expected: bool
):

View File

@ -30,7 +30,7 @@ REPO_URL = "https://github.com/awesome-developer/awesome-repo"
async def test_api_store(
api_client: TestClient,
store_addon: AddonStore,
repository: Repository,
test_repository: Repository,
caplog: pytest.LogCaptureFixture,
):
"""Test /store REST API."""
@ -38,7 +38,7 @@ async def test_api_store(
result = await resp.json()
assert result["data"]["addons"][-1]["slug"] == store_addon.slug
assert result["data"]["repositories"][-1]["slug"] == repository.slug
assert result["data"]["repositories"][-1]["slug"] == test_repository.slug
assert (
f"Add-on {store_addon.slug} not supported on this platform" not in caplog.text
@ -73,23 +73,25 @@ async def test_api_store_addons_addon_version(
@pytest.mark.asyncio
async def test_api_store_repositories(api_client: TestClient, repository: Repository):
async def test_api_store_repositories(
api_client: TestClient, test_repository: Repository
):
"""Test /store/repositories REST API."""
resp = await api_client.get("/store/repositories")
result = await resp.json()
assert result["data"][-1]["slug"] == repository.slug
assert result["data"][-1]["slug"] == test_repository.slug
@pytest.mark.asyncio
async def test_api_store_repositories_repository(
api_client: TestClient, repository: Repository
api_client: TestClient, test_repository: Repository
):
"""Test /store/repositories/{repository} REST API."""
resp = await api_client.get(f"/store/repositories/{repository.slug}")
resp = await api_client.get(f"/store/repositories/{test_repository.slug}")
result = await resp.json()
assert result["data"]["slug"] == repository.slug
assert result["data"]["slug"] == test_repository.slug
async def test_api_store_add_repository(
@ -106,18 +108,17 @@ async def test_api_store_add_repository(
assert response.status == 200
assert REPO_URL in coresys.store.repository_urls
assert isinstance(coresys.store.get_from_url(REPO_URL), Repository)
async def test_api_store_remove_repository(
api_client: TestClient, coresys: CoreSys, repository: Repository
api_client: TestClient, coresys: CoreSys, test_repository: Repository
):
"""Test DELETE /store/repositories/{repository} REST API."""
response = await api_client.delete(f"/store/repositories/{repository.slug}")
response = await api_client.delete(f"/store/repositories/{test_repository.slug}")
assert response.status == 200
assert repository.source not in coresys.store.repository_urls
assert repository.slug not in coresys.store.repositories
assert test_repository.source not in coresys.store.repository_urls
assert test_repository.slug not in coresys.store.repositories
async def test_api_store_update_healthcheck(
@ -329,7 +330,7 @@ async def test_store_addon_not_found(
("post", "/addons/local_ssh/update"),
],
)
@pytest.mark.usefixtures("repository")
@pytest.mark.usefixtures("test_repository")
async def test_store_addon_not_installed(api_client: TestClient, method: str, url: str):
"""Test store addon not installed error."""
resp = await api_client.request(method, url)

View File

@ -9,12 +9,7 @@ from blockbuster import BlockingError
import pytest
from supervisor.coresys import CoreSys
from supervisor.exceptions import (
HassioError,
HostNotSupportedError,
StoreGitError,
StoreNotFound,
)
from supervisor.exceptions import HassioError, HostNotSupportedError, StoreGitError
from supervisor.store.repository import Repository
from tests.api import common_test_api_advanced_logs
@ -38,8 +33,6 @@ async def test_api_supervisor_options_add_repository(
):
"""Test add a repository via POST /supervisor/options REST API."""
assert REPO_URL not in coresys.store.repository_urls
with pytest.raises(StoreNotFound):
coresys.store.get_from_url(REPO_URL)
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
@ -51,23 +44,22 @@ async def test_api_supervisor_options_add_repository(
assert response.status == 200
assert REPO_URL in coresys.store.repository_urls
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
api_client: TestClient, coresys: CoreSys, test_repository: Repository
):
"""Test remove a repository via POST /supervisor/options REST API."""
assert repository.source in coresys.store.repository_urls
assert repository.slug in coresys.store.repositories
assert test_repository.source in coresys.store.repository_urls
assert test_repository.slug in coresys.store.repositories
response = await api_client.post(
"/supervisor/options", json={"addons_repositories": []}
)
assert response.status == 200
assert repository.source not in coresys.store.repository_urls
assert repository.slug not in coresys.store.repositories
assert test_repository.source not in coresys.store.repository_urls
assert test_repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("git_error", [None, StoreGitError()])
@ -87,8 +79,6 @@ async def test_api_supervisor_options_repositories_skipped_on_error(
assert response.status == 400
assert len(coresys.resolution.suggestions) == 0
assert REPO_URL not in coresys.store.repository_urls
with pytest.raises(StoreNotFound):
coresys.store.get_from_url(REPO_URL)
async def test_api_supervisor_options_repo_error_with_config_change(

View File

@ -591,7 +591,7 @@ def run_supervisor_state(request: pytest.FixtureRequest) -> Generator[MagicMock]
@pytest.fixture
def store_addon(coresys: CoreSys, tmp_path, repository):
def store_addon(coresys: CoreSys, tmp_path, test_repository):
"""Store add-on fixture."""
addon_obj = AddonStore(coresys, "test_store_addon")
@ -604,18 +604,11 @@ def store_addon(coresys: CoreSys, tmp_path, repository):
@pytest.fixture
async def repository(coresys: CoreSys):
"""Repository fixture."""
coresys.store._data[ATTR_REPOSITORIES].remove(
"https://github.com/hassio-addons/repository"
)
coresys.store._data[ATTR_REPOSITORIES].remove(
"https://github.com/esphome/home-assistant-addon"
)
async def test_repository(coresys: CoreSys):
"""Test add-on store repository fixture."""
coresys.config._data[ATTR_ADDONS_CUSTOM_LIST] = []
with (
patch("supervisor.store.validate.BUILTIN_REPOSITORIES", {"local", "core"}),
patch("supervisor.store.git.GitRepo.load", return_value=None),
):
await coresys.store.load()
@ -633,7 +626,7 @@ async def repository(coresys: CoreSys):
@pytest.fixture
async def install_addon_ssh(coresys: CoreSys, repository):
async def install_addon_ssh(coresys: CoreSys, test_repository):
"""Install local_ssh add-on."""
store = coresys.addons.store[TEST_ADDON_SLUG]
await coresys.addons.data.install(store)
@ -645,7 +638,7 @@ async def install_addon_ssh(coresys: CoreSys, repository):
@pytest.fixture
async def install_addon_example(coresys: CoreSys, repository):
async def install_addon_example(coresys: CoreSys, test_repository):
"""Install local_example add-on."""
store = coresys.addons.store["local_example"]
await coresys.addons.data.install(store)

View File

@ -10,7 +10,7 @@ from supervisor.resolution.fixups.store_execute_remove import FixupStoreExecuteR
from supervisor.store.repository import Repository
async def test_fixup(coresys: CoreSys, repository: Repository):
async def test_fixup(coresys: CoreSys, test_repository: Repository):
"""Test fixup."""
store_execute_remove = FixupStoreExecuteRemove(coresys)
@ -18,16 +18,20 @@ async def test_fixup(coresys: CoreSys, repository: Repository):
coresys.resolution.add_suggestion(
Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, reference=repository.slug
SuggestionType.EXECUTE_REMOVE,
ContextType.STORE,
reference=test_repository.slug,
)
)
coresys.resolution.add_issue(
Issue(
IssueType.CORRUPT_REPOSITORY, ContextType.STORE, reference=repository.slug
IssueType.CORRUPT_REPOSITORY,
ContextType.STORE,
reference=test_repository.slug,
)
)
with patch.object(type(repository), "remove") as remove_repo:
with patch.object(type(test_repository), "remove") as remove_repo:
await store_execute_remove()
assert remove_repo.called
@ -36,4 +40,4 @@ async def test_fixup(coresys: CoreSys, repository: Repository):
assert len(coresys.resolution.suggestions) == 0
assert len(coresys.resolution.issues) == 0
assert repository.slug not in coresys.store.repositories
assert test_repository.slug not in coresys.store.repositories

View File

@ -3,14 +3,14 @@
from supervisor.coresys import CoreSys
def test_local_store(coresys: CoreSys, repository) -> None:
def test_local_store(coresys: CoreSys, test_repository) -> None:
"""Test loading from local store."""
assert coresys.store.get("local")
assert "local_ssh" in coresys.addons.store
def test_core_store(coresys: CoreSys, repository) -> None:
def test_core_store(coresys: CoreSys, test_repository) -> None:
"""Test loading from core store."""
assert coresys.store.get("core")

View File

@ -15,11 +15,20 @@ from supervisor.exceptions import (
StoreNotFound,
)
from supervisor.resolution.const import SuggestionType
from supervisor.store import BUILTIN_REPOSITORIES, StoreManager
from supervisor.store import StoreManager
from supervisor.store.addon import AddonStore
from supervisor.store.const import ALL_BUILTIN_REPOSITORIES
from supervisor.store.repository import Repository
def get_repository_by_url(store_manager: StoreManager, url: str) -> Repository:
"""Test helper to get repository by URL."""
for repository in store_manager.all:
if repository.source == url:
return repository
raise StoreNotFound()
@pytest.fixture(autouse=True)
def _auto_supervisor_internet(supervisor_internet):
# Use the supervisor_internet fixture to ensure that all tests has internet access
@ -33,7 +42,7 @@ async def test_add_valid_repository(
"""Test add custom repository."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.git.GitRepo.load", return_value=None),
patch(
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
@ -45,7 +54,7 @@ async def test_add_valid_repository(
else:
await store_manager.add_repository("http://example.com")
assert store_manager.get_from_url("http://example.com").validate()
assert get_repository_by_url(store_manager, "http://example.com").validate()
assert "http://example.com" in coresys.store.repository_urls
@ -54,17 +63,19 @@ 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.RepositoryGit.load", return_value=None),
patch("supervisor.store.git.GitRepo.load", return_value=None),
patch(
"pathlib.Path.read_text",
return_value="",
),
):
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
current + ["http://example.com"], issue_on_error=True
)
assert not await store_manager.get_from_url("http://example.com").validate()
assert not await get_repository_by_url(
store_manager, "http://example.com"
).validate()
assert "http://example.com" in coresys.store.repository_urls
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -77,7 +88,7 @@ async def test_error_on_invalid_repository(
"""Test invalid repository not added."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.git.GitRepo.load", return_value=None),
patch(
"pathlib.Path.read_text",
return_value="",
@ -91,8 +102,6 @@ async def test_error_on_invalid_repository(
assert "http://example.com" not in coresys.store.repository_urls
assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com")
async def test_add_invalid_repository_file(
@ -101,7 +110,7 @@ async def test_add_invalid_repository_file(
"""Test add invalid custom repository file."""
current = coresys.store.repository_urls
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.git.GitRepo.load", return_value=None),
patch(
"pathlib.Path.read_text",
return_value=json.dumps({"name": "Awesome repository"}),
@ -109,10 +118,12 @@ async def test_add_invalid_repository_file(
patch("pathlib.Path.exists", return_value=False),
):
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
current + ["http://example.com"], issue_on_error=True
)
assert not await store_manager.get_from_url("http://example.com").validate()
assert not await get_repository_by_url(
store_manager, "http://example.com"
).validate()
assert "http://example.com" in coresys.store.repository_urls
assert coresys.resolution.suggestions[-1].type == SuggestionType.EXECUTE_REMOVE
@ -133,14 +144,13 @@ 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.RepositoryGit.load", side_effect=git_error):
with patch("supervisor.store.git.GitRepo.load", side_effect=git_error):
await store_manager.update_repositories(
current + ["http://example.com"], add_with_errors=True
current + ["http://example.com"], issue_on_error=True
)
assert "http://example.com" in coresys.store.repository_urls
assert coresys.resolution.suggestions[-1].type == suggestion_type
assert isinstance(store_manager.get_from_url("http://example.com"), Repository)
@pytest.mark.parametrize(
@ -161,7 +171,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.RepositoryGit.load", side_effect=git_error),
patch("supervisor.store.git.GitRepo.load", side_effect=git_error),
pytest.raises(StoreError),
):
if use_update:
@ -171,8 +181,6 @@ async def test_error_on_repository_with_git_error(
assert "http://example.com" not in coresys.store.repository_urls
assert len(coresys.resolution.suggestions) == 0
with pytest.raises(StoreNotFound):
store_manager.get_from_url("http://example.com")
@pytest.mark.asyncio
@ -180,8 +188,8 @@ async def test_preinstall_valid_repository(
coresys: CoreSys, store_manager: StoreManager
):
"""Test add core repository valid."""
with patch("supervisor.store.repository.RepositoryGit.load", return_value=None):
await store_manager.update_repositories(BUILTIN_REPOSITORIES)
with patch("supervisor.store.git.GitRepo.load", return_value=None):
await store_manager.update_repositories(list(ALL_BUILTIN_REPOSITORIES))
def validate():
assert store_manager.get("core").validate()
@ -197,21 +205,21 @@ async def test_preinstall_valid_repository(
async def test_remove_repository(
coresys: CoreSys,
store_manager: StoreManager,
repository: Repository,
test_repository: Repository,
use_update: bool,
):
"""Test removing a custom repository."""
assert repository.source in coresys.store.repository_urls
assert repository.slug in coresys.store.repositories
assert test_repository.source in coresys.store.repository_urls
assert test_repository.slug in coresys.store.repositories
if use_update:
await store_manager.update_repositories([])
else:
await store_manager.remove_repository(repository)
await store_manager.remove_repository(test_repository)
assert repository.source not in coresys.store.repository_urls
assert repository.slug not in coresys.addons.store
assert repository.slug not in coresys.store.repositories
assert test_repository.source not in coresys.store.repository_urls
assert test_repository.slug not in coresys.addons.store
assert test_repository.slug not in coresys.store.repositories
@pytest.mark.parametrize("use_update", [True, False])
@ -243,7 +251,7 @@ 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.RepositoryGit.validate", return_value=True):
with patch("supervisor.store.repository.RepositoryGit.load", return_value=None):
with patch("supervisor.store.git.GitRepo.load", return_value=None):
await store_manager.update_repositories([])
store_manager.data.update.assert_called_once()
@ -254,7 +262,7 @@ async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManage
with (
patch(
"supervisor.store.repository.RepositoryGit.load",
"supervisor.store.git.GitRepo.load",
side_effect=[None, StoreGitError()],
),
pytest.raises(StoreError),
@ -268,27 +276,27 @@ async def test_update_partial_error(coresys: CoreSys, store_manager: StoreManage
async def test_error_adding_duplicate(
coresys: CoreSys, store_manager: StoreManager, repository: Repository
coresys: CoreSys, store_manager: StoreManager, test_repository: Repository
):
"""Test adding a duplicate repository causes an error."""
assert repository.source in coresys.store.repository_urls
assert test_repository.source in coresys.store.repository_urls
with (
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.git.GitRepo.load", return_value=None),
pytest.raises(StoreError),
):
await store_manager.add_repository(repository.source)
await store_manager.add_repository(test_repository.source)
async def test_add_with_update_repositories(
coresys: CoreSys, store_manager: StoreManager, repository: Repository
coresys: CoreSys, store_manager: StoreManager, test_repository: Repository
):
"""Test adding repositories to existing ones using update."""
assert repository.source in coresys.store.repository_urls
assert test_repository.source in coresys.store.repository_urls
assert "http://example.com" not in coresys.store.repository_urls
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.git.GitRepo.load", return_value=None),
patch(
"supervisor.utils.common.read_yaml_file",
return_value={"name": "Awesome repository"},
@ -297,7 +305,7 @@ async def test_add_with_update_repositories(
):
await store_manager.update_repositories(["http://example.com"], replace=False)
assert repository.source in coresys.store.repository_urls
assert test_repository.source in coresys.store.repository_urls
assert "http://example.com" in coresys.store.repository_urls
@ -326,7 +334,7 @@ async def test_repositories_loaded_ignore_updates(
):
"""Test repositories loaded whether or not supervisor needs an update."""
with (
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
patch("supervisor.store.git.GitRepo.load", return_value=None),
patch.object(
type(coresys.supervisor),
"need_update",

View File

@ -203,7 +203,7 @@ async def test_update_unavailable_addon(
)
async def test_install_unavailable_addon(
coresys: CoreSys,
repository: Repository,
test_repository: Repository,
caplog: pytest.LogCaptureFixture,
config: dict[str, Any],
log: str,