diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index f11bc03..76c7785 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -217,6 +217,13 @@ class HacsRepositories: """Return a list of downloaded repositories.""" return [repo for repo in self._repositories if repo.data.installed] + def category_downloaded(self, category: HacsCategory) -> bool: + """Check if a given category has been downloaded.""" + for repository in self.list_downloaded: + if repository.data.category == category: + return True + return False + def register(self, repository: HacsRepository, default: bool = False) -> None: """Register a repository.""" repo_id = str(repository.data.id) @@ -368,7 +375,7 @@ class HacsBase: status = HacsStatus() system = HacsSystem() validation: ValidationManager | None = None - version: str | None = None + version: AwesomeVersion | None = None @property def integration_dir(self) -> pathlib.Path: @@ -592,7 +599,7 @@ class HacsBase: repository.data.id = repository_id else: - if self.hass is not None and ((check and repository.data.new) or self.status.new): + if self.hass is not None and check and repository.data.new: self.async_dispatch( HacsDispatchEvent.REPOSITORY, { @@ -689,15 +696,23 @@ class HacsBase: self.async_dispatch(HacsDispatchEvent.STATUS, {}) - async def async_download_file(self, url: str, *, headers: dict | None = None) -> bytes | None: + async def async_download_file( + self, + url: str, + *, + headers: dict | None = None, + keep_url: bool = False, + nolog: bool = False, + **_, + ) -> bytes | None: """Download files, and return the content.""" if url is None: return None - if "tags/" in url: + if not keep_url and "tags/" in url: url = url.replace("tags/", "") - self.log.debug("Downloading %s", url) + self.log.debug("Trying to download %s", url) timeouts = 0 while timeouts < 5: @@ -733,7 +748,8 @@ class HacsBase: except ( BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except ) as exception: - self.log.exception("Download failed - %s", exception) + if not nolog: + self.log.exception("Download failed - %s", exception) return None @@ -763,24 +779,24 @@ class HacsBase: for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN): self.enable_hacs_category(HacsCategory(category)) - if self.configuration.experimental and self.core.ha_version >= "2023.4.0b0": + if self.configuration.experimental: self.enable_hacs_category(HacsCategory.TEMPLATE) - if HacsCategory.PYTHON_SCRIPT in self.hass.config.components: + if ( + HacsCategory.PYTHON_SCRIPT in self.hass.config.components + or self.repositories.category_downloaded(HacsCategory.PYTHON_SCRIPT) + ): self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT) - if self.hass.services.has_service("frontend", "reload_themes"): + if self.hass.services.has_service( + "frontend", "reload_themes" + ) or self.repositories.category_downloaded(HacsCategory.THEME): self.enable_hacs_category(HacsCategory.THEME) if self.configuration.appdaemon: self.enable_hacs_category(HacsCategory.APPDAEMON) if self.configuration.netdaemon: - downloaded_netdaemon = [ - x - for x in self.repositories.list_downloaded - if x.data.category == HacsCategory.NETDAEMON - ] - if len(downloaded_netdaemon) != 0: + if self.repositories.category_downloaded(HacsCategory.NETDAEMON): self.log.warning( "NetDaemon in HACS is deprectaded. It will stop working in the future. " "Please remove all your current NetDaemon repositories from HACS " @@ -871,15 +887,6 @@ class HacsBase: repository.repository_manifest.update_data( {**dict(HACS_MANIFEST_KEYS_TO_EXPORT), **manifest} ) - self.async_dispatch( - HacsDispatchEvent.REPOSITORY, - { - "id": 1337, - "action": "update", - "repository": repository.data.full_name, - "repository_id": repository.data.id, - }, - ) if category == "integration": self.status.inital_fetch_done = True @@ -896,6 +903,8 @@ class HacsBase: ) self.repositories.unregister(repository) + self.async_dispatch(HacsDispatchEvent.REPOSITORY, {}) + async def async_get_category_repositories(self, category: HacsCategory) -> None: """Get repositories from category.""" if self.system.disabled: diff --git a/custom_components/hacs/config_flow.py b/custom_components/hacs/config_flow.py index c769886..583f0f0 100644 --- a/custom_components/hacs/config_flow.py +++ b/custom_components/hacs/config_flow.py @@ -1,16 +1,23 @@ """Adds config flow for HACS.""" from __future__ import annotations +import asyncio +from contextlib import suppress from typing import TYPE_CHECKING -from aiogithubapi import GitHubDeviceAPI, GitHubException +from aiogithubapi import ( + GitHubDeviceAPI, + GitHubException, + GitHubLoginDeviceModel, + GitHubLoginOauthModel, +) from aiogithubapi.common.const import OAUTH_USER_LOGIN from awesomeversion import AwesomeVersion -from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow, OptionsFlow from homeassistant.const import __version__ as HAVERSION from homeassistant.core import callback +from homeassistant.data_entry_flow import UnknownFlow from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.event import async_call_later from homeassistant.loader import async_get_integration import voluptuous as vol @@ -33,23 +40,22 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant -class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class HacsFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for HACS.""" - hass: HomeAssistant - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def __init__(self): + hass: HomeAssistant + activation_task: asyncio.Task | None = None + device: GitHubDeviceAPI | None = None + + _registration: GitHubLoginDeviceModel | None = None + _activation: GitHubLoginOauthModel | None = None + _reauth: bool = False + + def __init__(self) -> None: """Initialize.""" self._errors = {} - self.device = None - self.activation = None - self.log = LOGGER - self._progress_task = None - self._login_device = None - self._reauth = False self._user_input = {} async def async_step_user(self, user_input): @@ -72,45 +78,58 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ## Initial form return await self._show_config_form(user_input) + @callback + def async_remove(self): + """Cleanup.""" + if self.activation_task and not self.activation_task.done(): + self.activation_task.cancel() + async def async_step_device(self, _user_input): - """Handle device steps""" + """Handle device steps.""" - async def _wait_for_activation(_=None): - if self._login_device is None or self._login_device.expires_in is None: - async_call_later(self.hass, 1, _wait_for_activation) - return + async def _wait_for_activation() -> None: + try: + response = await self.device.activation(device_code=self._registration.device_code) + self._activation = response.data + finally: - response = await self.device.activation(device_code=self._login_device.device_code) - self.activation = response.data - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + async def _progress(): + with suppress(UnknownFlow): + await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - if not self.activation: + self.hass.async_create_task(_progress()) + + if not self.device: integration = await async_get_integration(self.hass, DOMAIN) - if not self.device: - self.device = GitHubDeviceAPI( - client_id=CLIENT_ID, - session=aiohttp_client.async_get_clientsession(self.hass), - **{"client_name": f"HACS/{integration.version}"}, - ) - async_call_later(self.hass, 1, _wait_for_activation) + self.device = GitHubDeviceAPI( + client_id=CLIENT_ID, + session=aiohttp_client.async_get_clientsession(self.hass), + **{"client_name": f"HACS/{integration.version}"}, + ) try: response = await self.device.register() - self._login_device = response.data - return self.async_show_progress( - step_id="device", - progress_action="wait_for_device", - description_placeholders={ - "url": OAUTH_USER_LOGIN, - "code": self._login_device.user_code, - }, - ) + self._registration = response.data except GitHubException as exception: - self.log.error(exception) - return self.async_abort(reason="github") + LOGGER.exception(exception) + return self.async_abort(reason="could_not_register") - return self.async_show_progress_done(next_step_id="device_done") + if self.activation_task is None: + self.activation_task = self.hass.async_create_task(_wait_for_activation()) + + if self.activation_task.done(): + if (exception := self.activation_task.exception()) is not None: + LOGGER.exception(exception) + return self.async_show_progress_done(next_step_id="could_not_register") + return self.async_show_progress_done(next_step_id="device_done") + + return self.async_show_progress( + step_id="device", + progress_action="wait_for_device", + description_placeholders={ + "url": OAUTH_USER_LOGIN, + "code": self._registration.user_code, + }, + ) async def _show_config_form(self, user_input): """Show the configuration form to edit location data.""" @@ -146,7 +165,7 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if self._reauth: existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) self.hass.config_entries.async_update_entry( - existing_entry, data={**existing_entry.data, "token": self.activation.access_token} + existing_entry, data={**existing_entry.data, "token": self._activation.access_token} ) await self.hass.config_entries.async_reload(existing_entry.entry_id) return self.async_abort(reason="reauth_successful") @@ -154,13 +173,17 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="", data={ - "token": self.activation.access_token, + "token": self._activation.access_token, }, options={ "experimental": self._user_input.get("experimental", False), }, ) + async def async_step_could_not_register(self, _user_input=None): + """Handle issues that need transition await from progress step.""" + return self.async_abort(reason="could_not_register") + async def async_step_reauth(self, _user_input=None): """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() @@ -181,7 +204,7 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return HacsOptionsFlowHandler(config_entry) -class HacsOptionsFlowHandler(config_entries.OptionsFlow): +class HacsOptionsFlowHandler(OptionsFlow): """HACS config flow options handler.""" def __init__(self, config_entry): diff --git a/custom_components/hacs/frontend.py b/custom_components/hacs/frontend.py index c49c35f..6557517 100644 --- a/custom_components/hacs/frontend.py +++ b/custom_components/hacs/frontend.py @@ -13,6 +13,18 @@ from .hacs_frontend_experimental import ( locate_dir as experimental_locate_dir, ) +try: + from homeassistant.components.frontend import add_extra_js_url +except ImportError: + + def add_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None: + hacs: HacsBase = hass.data.get(DOMAIN) + hacs.log.error("Could not import add_extra_js_url from frontend.") + if "frontend_extra_module_url" not in hass.data: + hass.data["frontend_extra_module_url"] = set() + hass.data["frontend_extra_module_url"].add(url) + + if TYPE_CHECKING: from .base import HacsBase @@ -45,9 +57,7 @@ def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None: hass.http.register_static_path( f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js") ) - if "frontend_extra_module_url" not in hass.data: - hass.data["frontend_extra_module_url"] = set() - hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js") + add_extra_js_url(hass, f"{URL_BASE}/iconset.js") hacs.frontend_version = ( FE_VERSION if not hacs.configuration.experimental else EXPERIMENTAL_FE_VERSION diff --git a/custom_components/hacs/manifest.json b/custom_components/hacs/manifest.json index 467db16..f368b7b 100644 --- a/custom_components/hacs/manifest.json +++ b/custom_components/hacs/manifest.json @@ -19,5 +19,5 @@ "requirements": [ "aiogithubapi>=22.10.1" ], - "version": "1.33.0" + "version": "1.34.0" } \ No newline at end of file diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 320583a..4af6fb0 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -15,7 +15,6 @@ from aiogithubapi import ( AIOGitHubAPINotModifiedException, GitHubReleaseModel, ) -from aiogithubapi.const import BASE_API_URL from aiogithubapi.objects.repository import AIOGitHubAPIRepository import attr from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -28,6 +27,7 @@ from ..exceptions import ( HacsRepositoryArchivedException, HacsRepositoryExistException, ) +from ..types import DownloadableContent from ..utils.backup import Backup, BackupNetDaemon from ..utils.decode import decode_content from ..utils.decorator import concurrent @@ -38,6 +38,7 @@ from ..utils.path import is_safe from ..utils.queue_manager import QueueManager from ..utils.store import async_remove_store from ..utils.template import render_template +from ..utils.url import github_archive, github_release_asset from ..utils.validate import Validate from ..utils.version import ( version_left_higher_or_equal_then_right, @@ -558,40 +559,37 @@ class HacsRepository: return True - async def download_zip_files(self, validate) -> None: + async def download_zip_files(self, validate: Validate) -> None: """Download ZIP archive from repository release.""" + try: - contents = None - target_ref = self.ref.split("/")[1] - - for release in self.releases.objects: - self.logger.debug( - "%s ref: %s --- tag: %s", self.string, target_ref, release.tag_name - ) - if release.tag_name == target_ref: - contents = release.assets - break - - if not contents: - validate.errors.append(f"No assets found for release '{self.ref}'") - return - - download_queue = QueueManager(hass=self.hacs.hass) - - for content in contents or []: - download_queue.add(self.async_download_zip_file(content, validate)) - - await download_queue.execute() + await self.async_download_zip_file( + DownloadableContent( + name=self.repository_manifest.filename, + url=github_release_asset( + repository=self.data.full_name, + version=self.ref, + filename=self.repository_manifest.filename, + ), + ), + validate, + ) except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except - validate.errors.append("Download was not completed") + validate.errors.append( + f"Download of {self.repository_manifest.filename} was not completed" + ) - async def async_download_zip_file(self, content, validate) -> None: + async def async_download_zip_file( + self, + content: DownloadableContent, + validate: Validate, + ) -> None: """Download ZIP archive from repository release.""" try: - filecontent = await self.hacs.async_download_file(content.browser_download_url) + filecontent = await self.hacs.async_download_file(content["url"]) if filecontent is None: - validate.errors.append(f"[{content.name}] was not downloaded") + validate.errors.append(f"Failed to download {content['url']}") return temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) @@ -608,16 +606,17 @@ class HacsRepository: shutil.rmtree(temp_dir) if result: - self.logger.info("%s Download of %s completed", self.string, content.name) + self.logger.info("%s Download of %s completed", self.string, content["name"]) await self.hacs.hass.async_add_executor_job(cleanup_temp_dir) return - validate.errors.append(f"[{content.name}] was not downloaded") + validate.errors.append(f"[{content['name']}] was not downloaded") except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except validate.errors.append("Download was not completed") - async def download_content(self) -> None: + async def download_content(self, version: string | None = None) -> None: """Download the content of a directory.""" + contents: list[FileInformation] | None = None if self.hacs.configuration.experimental: if ( not self.repository_manifest.zip_release @@ -631,9 +630,15 @@ class HacsRepository: except HacsException as exception: self.logger.exception(exception) - contents = self.gather_files_to_download() if self.repository_manifest.filename: self.logger.debug("%s %s", self.string, self.repository_manifest.filename) + + if self.content.path.remote == "release" and version is not None: + contents = await self.release_contents(version) + + if not contents: + contents = self.gather_files_to_download() + if not contents: raise HacsException("No content to download") @@ -654,15 +659,17 @@ class HacsRepository: if not ref: raise HacsException("Missing required elements.") - url = f"{BASE_API_URL}/repos/{self.data.full_name}/zipball/{ref}" - filecontent = await self.hacs.async_download_file( - url, - headers={ - "Authorization": f"token {self.hacs.configuration.token}", - "User-Agent": f"HACS/{self.hacs.version}", - }, + github_archive(repository=self.data.full_name, version=ref, variant="tags"), + keep_url=True, + nolog=True, ) + + if filecontent is None: + filecontent = await self.hacs.async_download_file( + github_archive(repository=self.data.full_name, version=ref, variant="heads"), + keep_url=True, + ) if filecontent is None: raise HacsException(f"[{self}] Failed to download zipball") @@ -681,8 +688,13 @@ class HacsRepository: and filename != self.content.path.remote ): path.filename = filename.replace(self.content.path.remote, "") + if path.filename == "/": + # Blank files is not valid, and will start to throw in Python 3.12 + continue extractable.append(path) + if len(extractable) == 0: + raise HacsException("No content to extract") zip_file.extractall(self.content.path.local, extractable) def cleanup_temp_dir(): @@ -732,25 +744,7 @@ class HacsRepository: if not info_files: return "" - try: - response = await self.hacs.async_github_api_method( - method=self.hacs.githubapi.repos.contents.get, - raise_exception=False, - repository=self.data.full_name, - path=info_files[0], - ) - if response: - return render_template( - self.hacs, - decode_content(response.data.content) - .replace(" None: """Run remove tasks.""" @@ -799,7 +793,7 @@ class HacsRepository: try: if self.data.category == "python_script": - local_path = f"{self.content.path.local}/{self.data.name}.py" + local_path = f"{self.content.path.local}/{self.data.file_name}" elif self.data.category == "template": local_path = f"{self.content.path.local}/{self.data.file_name}" elif self.data.category == "theme": @@ -888,7 +882,7 @@ class HacsRepository: await self.async_pre_install() self.logger.info("%s Pre installation steps completed", self.string) - async def async_install(self) -> None: + async def async_install(self, *, version: str | None = None, **_) -> None: """Run install steps.""" await self._async_pre_install() self.hacs.async_dispatch( @@ -896,7 +890,7 @@ class HacsRepository: {"repository": self.data.full_name, "progress": 30}, ) self.logger.info("%s Running installation steps", self.string) - await self.async_install_repository() + await self.async_install_repository(version=version) self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, {"repository": self.data.full_name, "progress": 90}, @@ -927,10 +921,10 @@ class HacsRepository: ) self.logger.info("%s Post installation steps completed", self.string) - async def async_install_repository(self) -> None: + async def async_install_repository(self, *, version: str | None = None, **_) -> None: """Common installation steps of the repository.""" persistent_directory = None - await self.update_repository(force=True) + await self.update_repository(force=version is None) if self.content.path.local is None: raise HacsException("repository.content.path.local is None") self.validate.errors.clear() @@ -938,11 +932,11 @@ class HacsRepository: if not self.can_download: raise HacsException("The version of Home Assistant is not compatible with this version") - version = self.version_to_download() - if version == self.data.default_branch: - self.ref = version + version_to_install = version or self.version_to_download() + if version_to_install == self.data.default_branch: + self.ref = version_to_install else: - self.ref = f"tags/{version}" + self.ref = f"tags/{version_to_install}" self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, @@ -970,16 +964,17 @@ class HacsRepository: self.hacs.log.debug("%s Local path is set to %s", self.string, self.content.path.local) self.hacs.log.debug("%s Remote path is set to %s", self.string, self.content.path.remote) + self.hacs.log.debug("%s Version to install: %s", self.string, version_to_install) self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, {"repository": self.data.full_name, "progress": 50}, ) - if self.repository_manifest.zip_release and version != self.data.default_branch: + if self.repository_manifest.zip_release and self.repository_manifest.filename: await self.download_zip_files(self.validate) else: - await self.download_content() + await self.download_content(version_to_install) self.hacs.async_dispatch( HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, @@ -1010,10 +1005,10 @@ class HacsRepository: self.data.installed = True self.data.installed_commit = self.data.last_commit - if version == self.data.default_branch: + if version_to_install == self.data.default_branch: self.data.installed_version = None else: - self.data.installed_version = version + self.data.installed_version = version_to_install async def async_get_legacy_repository_object( self, @@ -1228,6 +1223,25 @@ class HacsRepository: files.append(FileInformation(path.download_url, path.full_path, path.filename)) return files + async def release_contents(self, version: str | None = None) -> list[FileInformation] | None: + """Gather the contents of a release.""" + release = await self.hacs.async_github_api_method( + method=self.hacs.githubapi.generic, + endpoint=f"/repos/{self.data.full_name}/releases/tags/{version}", + raise_exception=False, + ) + if release is None: + return None + + return [ + FileInformation( + url=asset.get("browser_download_url"), + path=asset.get("name"), + name=asset.get("name"), + ) + for asset in release.data.get("assets", []) + ] + @concurrent(concurrenttasks=10) async def dowload_repository_content(self, content: FileInformation) -> None: """Download content.""" @@ -1303,3 +1317,58 @@ class HacsRepository: return self.data.selected_tag return self.data.default_branch or "main" + + async def get_documentation( + self, + *, + filename: str | None = None, + **kwargs, + ) -> str | None: + """Get the documentation of the repository.""" + if filename is None: + return None + + version = ( + (self.data.installed_version or self.data.installed_commit) + if self.data.installed + else (self.data.last_version or self.data.last_commit or self.ref) + ) + self.logger.debug( + "%s Getting documentation for version=%s,filename=%s", + self.string, + version, + filename, + ) + if version is None: + return None + + result = await self.hacs.async_download_file( + f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/{filename}", + nolog=True, + ) + + return ( + render_template( + self.hacs, + result.decode(encoding="utf-8") + .replace(" HacsManifest | None: + """Get the hacs.json file of the repository.""" + self.logger.debug("%s Getting hacs.json for version=%s", self.string, version) + try: + result = await self.hacs.async_download_file( + f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/hacs.json", + nolog=True, + ) + if result is None: + return None + return HacsManifest.from_dict(json_loads(result)) + except Exception: # pylint: disable=broad-except + return None diff --git a/custom_components/hacs/update.py b/custom_components/hacs/update.py index 31da0ce..163aed5 100644 --- a/custom_components/hacs/update.py +++ b/custom_components/hacs/update.py @@ -3,14 +3,16 @@ from __future__ import annotations from typing import Any -from homeassistant.components.update import UpdateEntity -from homeassistant.core import callback +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.core import HomeAssistantError, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .base import HacsBase from .const import DOMAIN from .entity import HacsRepositoryEntity from .enums import HacsCategory, HacsDispatchEvent +from .exceptions import HacsException +from .repositories.base import HacsManifest async def async_setup_entry(hass, _config_entry, async_add_devices): @@ -25,13 +27,12 @@ async def async_setup_entry(hass, _config_entry, async_add_devices): class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity): """Update entities for repositories downloaded with HACS.""" - @property - def supported_features(self) -> int | None: - """Return the supported features of the entity.""" - features = 4 | 16 - if self.repository.can_download: - features = features | 1 - return features + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) @property def name(self) -> str | None: @@ -75,15 +76,60 @@ class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity): return f"https://brands.home-assistant.io/_/{self.repository.data.domain}/icon.png" + async def _ensure_capabilities(self, version: str | None, **kwargs: Any) -> None: + """Ensure that the entity has capabilities.""" + target_manifest: HacsManifest | None = None + if version is None: + if not self.repository.can_download: + raise HomeAssistantError( + f"This {self.repository.data.category.value} is not available for download." + ) + return + + if version == self.repository.data.last_version: + target_manifest = self.repository.repository_manifest + else: + target_manifest = await self.repository.get_hacs_json(version=version) + + if target_manifest is None: + raise HomeAssistantError( + f"The version {version} for this {self.repository.data.category.value} can not be used with HACS." + ) + + if ( + target_manifest.homeassistant is not None + and self.hacs.core.ha_version < target_manifest.homeassistant + ): + raise HomeAssistantError( + f"This version requires Home Assistant {target_manifest.homeassistant} or newer." + ) + if target_manifest.hacs is not None and self.hacs.version < target_manifest.hacs: + raise HomeAssistantError(f"This version requires HACS {target_manifest.hacs} or newer.") + async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None: """Install an update.""" + await self._ensure_capabilities(version) + self.repository.logger.info("Starting update, %s", version) if self.repository.display_version_or_commit == "version": self._update_in_progress(progress=10) - self.repository.data.selected_tag = self.latest_version - await self.repository.update_repository(force=True) + if not version: + await self.repository.update_repository(force=True) + else: + self.repository.ref = version + self.repository.data.selected_tag = version + self.repository.force_branch = version is not None self._update_in_progress(progress=20) - await self.repository.async_install() - self._update_in_progress(progress=False) + + try: + await self.repository.async_install(version=version) + except HacsException as exception: + raise HomeAssistantError( + f"Downloading {self.repository.data.full_name} with version {version or self.repository.data.last_version or self.repository.data.last_commit} failed with ({exception})" + ) from exception + finally: + self.repository.data.selected_tag = None + self.repository.force_branch = False + self._update_in_progress(progress=False) async def async_release_notes(self) -> str | None: """Return the release notes.""" diff --git a/custom_components/hacs/utils/validate.py b/custom_components/hacs/utils/validate.py index 52a0032..c0ba455 100644 --- a/custom_components/hacs/utils/validate.py +++ b/custom_components/hacs/utils/validate.py @@ -45,9 +45,9 @@ HACS_MANIFEST_JSON_SCHEMA = vol.Schema( vol.Optional("content_in_root"): bool, vol.Optional("country"): _country_validator, vol.Optional("filename"): str, - vol.Optional("hacs"): vol.Coerce(AwesomeVersion), + vol.Optional("hacs"): str, vol.Optional("hide_default_branch"): bool, - vol.Optional("homeassistant"): vol.Coerce(AwesomeVersion), + vol.Optional("homeassistant"): str, vol.Optional("persistent_directory"): str, vol.Optional("render_readme"): bool, vol.Optional("zip_release"): bool, diff --git a/custom_components/hacs/validate/hacsjson.py b/custom_components/hacs/validate/hacsjson.py index 6ba6d2b..4d00007 100644 --- a/custom_components/hacs/validate/hacsjson.py +++ b/custom_components/hacs/validate/hacsjson.py @@ -1,9 +1,10 @@ from __future__ import annotations from voluptuous.error import Invalid +from voluptuous.humanize import humanize_error -from ..enums import RepositoryFile -from ..repositories.base import HacsRepository +from ..enums import HacsCategory, RepositoryFile +from ..repositories.base import HacsManifest, HacsRepository from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA from .base import ActionValidationBase, ValidationException @@ -25,6 +26,10 @@ class Validator(ActionValidationBase): content = await self.repository.async_get_hacs_json(self.repository.ref) try: - HACS_MANIFEST_JSON_SCHEMA(content) + hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(content)) except Invalid as exception: - raise ValidationException(exception) from exception + raise ValidationException(humanize_error(content, exception)) from exception + + if self.repository.data.category == HacsCategory.INTEGRATION: + if hacsjson.zip_release and not hacsjson.filename: + raise ValidationException("zip_release is True, but filename is not set")