diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index a13053b2af5..28816999fb3 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -375,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: @@ -696,15 +696,23 @@ async def startup_tasks(self, _=None) -> None: 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: @@ -740,7 +748,8 @@ async def async_download_file(self, url: str, *, headers: dict | None = None) -> 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 diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 6ae56fd30bf..69cdde0c803 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -15,7 +15,6 @@ 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 @@ 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.queue_manager import QueueManager from ..utils.store import async_remove_store from ..utils.template import render_template +from ..utils.url import archive_download, asset_download from ..utils.validate import Validate from ..utils.version import ( version_left_higher_or_equal_then_right, @@ -558,30 +559,47 @@ async def common_update(self, ignore_issues=False, force=False, skip_releases=Fa 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] + contents: list[DownloadableContent] = [] + target_ref = self.ref.split("/")[1] + if self.repository_manifest.zip_release: + contents.append( + DownloadableContent( + name=self.repository_manifest.filename, + url=asset_download( + repository=self.data.full_name, + version=target_ref, + filenme=self.repository_manifest.filename, + ), + ) + ) + else: 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 + if release.tag_name == target_ref and release.assets: + contents = [ + DownloadableContent( + name=asset.name, + url=asset.browser_download_url, + ) + for asset in release.assets + ] break - if not contents: + if len(contents) == 0: 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 = QueueManager(hass=self.hacs.hass) + try: + for content in contents: if ( self.repository_manifest.zip_release - and content.name != self.repository_manifest.filename + and content["name"] != self.repository_manifest.filename ): continue download_queue.add(self.async_download_zip_file(content, validate)) @@ -594,13 +612,17 @@ async def download_zip_files(self, validate) -> None: except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except validate.errors.append("Download 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"[{content['name']}] was not downloaded") return temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) @@ -617,11 +639,11 @@ def cleanup_temp_dir(): 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") @@ -663,19 +685,22 @@ async def download_repository_zip(self): 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}", - }, + archive_download(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( + archive_download(repository=self.data.full_name, version=ref, variant="heads"), + keep_url=True, + ) if filecontent is None: raise HacsException(f"[{self}] Failed to download zipball") temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) + tmp_extract = f"{temp_dir}/extracted" temp_file = f"{temp_dir}/{self.repository_manifest.filename}" result = await self.hacs.async_save_file(temp_file, filecontent) if not result: @@ -692,6 +717,26 @@ async def download_repository_zip(self): path.filename = filename.replace(self.content.path.remote, "") extractable.append(path) + if filename == "hacs.json": + path.filename = "hacs.json" + zip_file.extract(path, tmp_extract) + with open(f"{tmp_extract}/hacs.json", encoding="utf-8") as hacsfile: + hacs_manifest = json_loads(hacsfile.read()) + if ( + hacs_version := hacs_manifest.get("hacs") + ) and hacs_version > self.hacs.version: + raise HacsException( + f"This repository requires HACS version {hacs_manifest['hacs']}, you have {self.hacs.version}" + ) + if ( + homeassistant_version := hacs_manifest["homeassistant"] + ) and homeassistant_version > self.hacs.core.ha_version: + raise HacsException( + f"This repository requires Home Assistant version {hacs_manifest['homeassistant']}, you have {self.hacs.core.ha_version}" + ) + + if len(extractable) == 0: + raise HacsException("No content to extract") zip_file.extractall(self.content.path.local, extractable) def cleanup_temp_dir(): @@ -897,7 +942,7 @@ async def _async_pre_install(self) -> None: 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( @@ -905,7 +950,7 @@ async def async_install(self) -> None: {"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}, @@ -936,10 +981,10 @@ async def _async_post_install(self) -> None: ) 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() @@ -947,11 +992,11 @@ async def async_install_repository(self) -> None: 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, @@ -985,7 +1030,7 @@ async def async_install_repository(self) -> None: {"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 version_to_install != self.data.default_branch: await self.download_zip_files(self.validate) else: await self.download_content() @@ -1019,10 +1064,10 @@ async def async_install_repository(self) -> None: 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, diff --git a/custom_components/hacs/types.py b/custom_components/hacs/types.py new file mode 100644 index 00000000000..2b2ac01af5a --- /dev/null +++ b/custom_components/hacs/types.py @@ -0,0 +1,10 @@ +"""Custom HACS types.""" + +from typing import TypedDict + + +class DownloadableContent(TypedDict): + """Downloadable content.""" + + url: str + name: str diff --git a/custom_components/hacs/update.py b/custom_components/hacs/update.py index 31da0cee442..5acc5b91547 100644 --- a/custom_components/hacs/update.py +++ b/custom_components/hacs/update.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.update import UpdateEntity -from homeassistant.core import callback +from homeassistant.core import HomeAssistantError, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .base import HacsBase @@ -25,13 +25,7 @@ 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 = 1 | 2 | 4 | 16 @property def name(self) -> str | None: @@ -75,14 +69,37 @@ def entity_picture(self) -> str | None: return f"https://brands.home-assistant.io/_/{self.repository.data.domain}/icon.png" + def _ensure_capabilities(self, version: str | None, **kwargs: Any) -> None: + """Ensure that the entity has capabilities.""" + 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 ( + self.repository.display_version_or_commit != "version" + or self.repository.repository_manifest.hide_default_branch + ): + raise HomeAssistantError( + f"This {self.repository.data.category.value} does not support version selection." + ) + async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None: """Install an update.""" + 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() + + await self.repository.async_install(version=version) self._update_in_progress(progress=False) async def async_release_notes(self) -> str | None: diff --git a/custom_components/hacs/utils/url.py b/custom_components/hacs/utils/url.py new file mode 100644 index 00000000000..8f0ab6cdc1e --- /dev/null +++ b/custom_components/hacs/utils/url.py @@ -0,0 +1,22 @@ +"""Various URL utils for HACS.""" +import re + +GIT_SHA = re.compile(r"^[a-fA-F0-9]{40}$") + + +def asset_download(repository: str, version: str, filenme: str) -> str: + """Generate a download URL for a release asset.""" + return f"https://github.com/{repository}/releases/download/{version}/{filenme}" + + +def archive_download( + *, + repository: str, + version: str, + variant: str = "heads", + **_, +) -> str: + """Generate a download URL for a repository zip.""" + if GIT_SHA.match(version): + return f"https://github.com/{repository}/archive/{version}.zip" + return f"https://github.com/{repository}/archive/refs/{variant}/{version}.zip"