diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index b2cb5686b..9f476ce55 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -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 ) diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index 3b7d81541..54f605863 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -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() diff --git a/supervisor/store/const.py b/supervisor/store/const.py index a472e095b..39638058e 100644 --- a/supervisor/store/const.py +++ b/supervisor/store/const.py @@ -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} diff --git a/supervisor/store/data.py b/supervisor/store/data.py index 8dda62c96..5450c2701 100644 --- a/supervisor/store/data.py +++ b/supervisor/store/data.py @@ -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, diff --git a/supervisor/store/repository.py b/supervisor/store/repository.py index 5f873297f..f07ab4b7b 100644 --- a/supervisor/store/repository.py +++ b/supervisor/store/repository.py @@ -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.""" diff --git a/supervisor/store/validate.py b/supervisor/store/validate.py index e01b27eb9..31701a625 100644 --- a/supervisor/store/validate.py +++ b/supervisor/store/validate.py @@ -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, diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index e8fd4365d..0bea2cc54 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -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.""" diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 1590fe3fe..f5843d001 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -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"] diff --git a/tests/api/test_addons.py b/tests/api/test_addons.py index 053a4de3c..a3574ff67 100644 --- a/tests/api/test_addons.py +++ b/tests/api/test_addons.py @@ -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 ): diff --git a/tests/api/test_store.py b/tests/api/test_store.py index 1f82e96b3..48d1cd7f7 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -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) diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index c24fdaf72..c39151437 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -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( diff --git a/tests/conftest.py b/tests/conftest.py index f6c8d8815..6625d7601 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/resolution/fixup/test_store_execute_remove.py b/tests/resolution/fixup/test_store_execute_remove.py index 980d1eb53..94b50e7ba 100644 --- a/tests/resolution/fixup/test_store_execute_remove.py +++ b/tests/resolution/fixup/test_store_execute_remove.py @@ -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 diff --git a/tests/store/test_builtin_stores.py b/tests/store/test_builtin_stores.py index 85345d5c0..ae56b6063 100644 --- a/tests/store/test_builtin_stores.py +++ b/tests/store/test_builtin_stores.py @@ -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") diff --git a/tests/store/test_custom_repository.py b/tests/store/test_custom_repository.py index d94a527e5..16dc33877 100644 --- a/tests/store/test_custom_repository.py +++ b/tests/store/test_custom_repository.py @@ -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", diff --git a/tests/store/test_store_manager.py b/tests/store/test_store_manager.py index 385229c43..2e5862f9e 100644 --- a/tests/store/test_store_manager.py +++ b/tests/store/test_store_manager.py @@ -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,