Upgrade HACS

This commit is contained in:
2024-02-26 07:59:30 -08:00
parent 6dbd89b476
commit 195dc27e29
8 changed files with 326 additions and 164 deletions

View File

@@ -217,6 +217,13 @@ class HacsRepositories:
"""Return a list of downloaded repositories.""" """Return a list of downloaded repositories."""
return [repo for repo in self._repositories if repo.data.installed] 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: def register(self, repository: HacsRepository, default: bool = False) -> None:
"""Register a repository.""" """Register a repository."""
repo_id = str(repository.data.id) repo_id = str(repository.data.id)
@@ -368,7 +375,7 @@ class HacsBase:
status = HacsStatus() status = HacsStatus()
system = HacsSystem() system = HacsSystem()
validation: ValidationManager | None = None validation: ValidationManager | None = None
version: str | None = None version: AwesomeVersion | None = None
@property @property
def integration_dir(self) -> pathlib.Path: def integration_dir(self) -> pathlib.Path:
@@ -592,7 +599,7 @@ class HacsBase:
repository.data.id = repository_id repository.data.id = repository_id
else: 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( self.async_dispatch(
HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.REPOSITORY,
{ {
@@ -689,15 +696,23 @@ class HacsBase:
self.async_dispatch(HacsDispatchEvent.STATUS, {}) 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.""" """Download files, and return the content."""
if url is None: if url is None:
return None return None
if "tags/" in url: if not keep_url and "tags/" in url:
url = url.replace("tags/", "") url = url.replace("tags/", "")
self.log.debug("Downloading %s", url) self.log.debug("Trying to download %s", url)
timeouts = 0 timeouts = 0
while timeouts < 5: while timeouts < 5:
@@ -733,6 +748,7 @@ class HacsBase:
except ( except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception: ) as exception:
if not nolog:
self.log.exception("Download failed - %s", exception) self.log.exception("Download failed - %s", exception)
return None return None
@@ -763,24 +779,24 @@ class HacsBase:
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN): for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN):
self.enable_hacs_category(HacsCategory(category)) 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) 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) 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) self.enable_hacs_category(HacsCategory.THEME)
if self.configuration.appdaemon: if self.configuration.appdaemon:
self.enable_hacs_category(HacsCategory.APPDAEMON) self.enable_hacs_category(HacsCategory.APPDAEMON)
if self.configuration.netdaemon: if self.configuration.netdaemon:
downloaded_netdaemon = [ if self.repositories.category_downloaded(HacsCategory.NETDAEMON):
x
for x in self.repositories.list_downloaded
if x.data.category == HacsCategory.NETDAEMON
]
if len(downloaded_netdaemon) != 0:
self.log.warning( self.log.warning(
"NetDaemon in HACS is deprectaded. It will stop working in the future. " "NetDaemon in HACS is deprectaded. It will stop working in the future. "
"Please remove all your current NetDaemon repositories from HACS " "Please remove all your current NetDaemon repositories from HACS "
@@ -871,15 +887,6 @@ class HacsBase:
repository.repository_manifest.update_data( repository.repository_manifest.update_data(
{**dict(HACS_MANIFEST_KEYS_TO_EXPORT), **manifest} {**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": if category == "integration":
self.status.inital_fetch_done = True self.status.inital_fetch_done = True
@@ -896,6 +903,8 @@ class HacsBase:
) )
self.repositories.unregister(repository) self.repositories.unregister(repository)
self.async_dispatch(HacsDispatchEvent.REPOSITORY, {})
async def async_get_category_repositories(self, category: HacsCategory) -> None: async def async_get_category_repositories(self, category: HacsCategory) -> None:
"""Get repositories from category.""" """Get repositories from category."""
if self.system.disabled: if self.system.disabled:

View File

@@ -1,16 +1,23 @@
"""Adds config flow for HACS.""" """Adds config flow for HACS."""
from __future__ import annotations from __future__ import annotations
import asyncio
from contextlib import suppress
from typing import TYPE_CHECKING 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 aiogithubapi.common.const import OAUTH_USER_LOGIN
from awesomeversion import AwesomeVersion 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.const import __version__ as HAVERSION
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import UnknownFlow
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.event import async_call_later
from homeassistant.loader import async_get_integration from homeassistant.loader import async_get_integration
import voluptuous as vol import voluptuous as vol
@@ -33,23 +40,22 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for HACS.""" """Config flow for HACS."""
hass: HomeAssistant
VERSION = 1 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.""" """Initialize."""
self._errors = {} 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 = {} self._user_input = {}
async def async_step_user(self, user_input): async def async_step_user(self, user_input):
@@ -72,45 +78,58 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
## Initial form ## Initial form
return await self._show_config_form(user_input) 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): async def async_step_device(self, _user_input):
"""Handle device steps""" """Handle device steps."""
async def _wait_for_activation(_=None): async def _wait_for_activation() -> None:
if self._login_device is None or self._login_device.expires_in is None: try:
async_call_later(self.hass, 1, _wait_for_activation) response = await self.device.activation(device_code=self._registration.device_code)
return self._activation = response.data
finally:
response = await self.device.activation(device_code=self._login_device.device_code) async def _progress():
self.activation = response.data with suppress(UnknownFlow):
self.hass.async_create_task( await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
) self.hass.async_create_task(_progress())
if not self.activation:
integration = await async_get_integration(self.hass, DOMAIN)
if not self.device: if not self.device:
integration = await async_get_integration(self.hass, DOMAIN)
self.device = GitHubDeviceAPI( self.device = GitHubDeviceAPI(
client_id=CLIENT_ID, client_id=CLIENT_ID,
session=aiohttp_client.async_get_clientsession(self.hass), session=aiohttp_client.async_get_clientsession(self.hass),
**{"client_name": f"HACS/{integration.version}"}, **{"client_name": f"HACS/{integration.version}"},
) )
async_call_later(self.hass, 1, _wait_for_activation)
try: try:
response = await self.device.register() response = await self.device.register()
self._login_device = response.data self._registration = response.data
except GitHubException as exception:
LOGGER.exception(exception)
return self.async_abort(reason="could_not_register")
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( return self.async_show_progress(
step_id="device", step_id="device",
progress_action="wait_for_device", progress_action="wait_for_device",
description_placeholders={ description_placeholders={
"url": OAUTH_USER_LOGIN, "url": OAUTH_USER_LOGIN,
"code": self._login_device.user_code, "code": self._registration.user_code,
}, },
) )
except GitHubException as exception:
self.log.error(exception)
return self.async_abort(reason="github")
return self.async_show_progress_done(next_step_id="device_done")
async def _show_config_form(self, user_input): async def _show_config_form(self, user_input):
"""Show the configuration form to edit location data.""" """Show the configuration form to edit location data."""
@@ -146,7 +165,7 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if self._reauth: if self._reauth:
existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
self.hass.config_entries.async_update_entry( 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) await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reauth_successful")
@@ -154,13 +173,17 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry( return self.async_create_entry(
title="", title="",
data={ data={
"token": self.activation.access_token, "token": self._activation.access_token,
}, },
options={ options={
"experimental": self._user_input.get("experimental", False), "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): async def async_step_reauth(self, _user_input=None):
"""Perform reauth upon an API authentication error.""" """Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm() return await self.async_step_reauth_confirm()
@@ -181,7 +204,7 @@ class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return HacsOptionsFlowHandler(config_entry) return HacsOptionsFlowHandler(config_entry)
class HacsOptionsFlowHandler(config_entries.OptionsFlow): class HacsOptionsFlowHandler(OptionsFlow):
"""HACS config flow options handler.""" """HACS config flow options handler."""
def __init__(self, config_entry): def __init__(self, config_entry):

View File

@@ -13,6 +13,18 @@ from .hacs_frontend_experimental import (
locate_dir as experimental_locate_dir, 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: if TYPE_CHECKING:
from .base import HacsBase from .base import HacsBase
@@ -45,9 +57,7 @@ def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
hass.http.register_static_path( hass.http.register_static_path(
f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js") f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
) )
if "frontend_extra_module_url" not in hass.data: add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
hass.data["frontend_extra_module_url"] = set()
hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js")
hacs.frontend_version = ( hacs.frontend_version = (
FE_VERSION if not hacs.configuration.experimental else EXPERIMENTAL_FE_VERSION FE_VERSION if not hacs.configuration.experimental else EXPERIMENTAL_FE_VERSION

View File

@@ -19,5 +19,5 @@
"requirements": [ "requirements": [
"aiogithubapi>=22.10.1" "aiogithubapi>=22.10.1"
], ],
"version": "1.33.0" "version": "1.34.0"
} }

View File

@@ -15,7 +15,6 @@ from aiogithubapi import (
AIOGitHubAPINotModifiedException, AIOGitHubAPINotModifiedException,
GitHubReleaseModel, GitHubReleaseModel,
) )
from aiogithubapi.const import BASE_API_URL
from aiogithubapi.objects.repository import AIOGitHubAPIRepository from aiogithubapi.objects.repository import AIOGitHubAPIRepository
import attr import attr
from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers import device_registry as dr, issue_registry as ir
@@ -28,6 +27,7 @@ from ..exceptions import (
HacsRepositoryArchivedException, HacsRepositoryArchivedException,
HacsRepositoryExistException, HacsRepositoryExistException,
) )
from ..types import DownloadableContent
from ..utils.backup import Backup, BackupNetDaemon from ..utils.backup import Backup, BackupNetDaemon
from ..utils.decode import decode_content from ..utils.decode import decode_content
from ..utils.decorator import concurrent from ..utils.decorator import concurrent
@@ -38,6 +38,7 @@ from ..utils.path import is_safe
from ..utils.queue_manager import QueueManager from ..utils.queue_manager import QueueManager
from ..utils.store import async_remove_store from ..utils.store import async_remove_store
from ..utils.template import render_template from ..utils.template import render_template
from ..utils.url import github_archive, github_release_asset
from ..utils.validate import Validate from ..utils.validate import Validate
from ..utils.version import ( from ..utils.version import (
version_left_higher_or_equal_then_right, version_left_higher_or_equal_then_right,
@@ -558,40 +559,37 @@ class HacsRepository:
return True 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.""" """Download ZIP archive from repository release."""
try:
contents = None
target_ref = self.ref.split("/")[1]
for release in self.releases.objects: try:
self.logger.debug( await self.async_download_zip_file(
"%s ref: %s --- tag: %s", self.string, target_ref, release.tag_name DownloadableContent(
name=self.repository_manifest.filename,
url=github_release_asset(
repository=self.data.full_name,
version=self.ref,
filename=self.repository_manifest.filename,
),
),
validate,
) )
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()
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except 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.""" """Download ZIP archive from repository release."""
try: 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: if filecontent is None:
validate.errors.append(f"[{content.name}] was not downloaded") validate.errors.append(f"Failed to download {content['url']}")
return return
temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp)
@@ -608,16 +606,17 @@ class HacsRepository:
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
if result: 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) await self.hacs.hass.async_add_executor_job(cleanup_temp_dir)
return 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 except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
validate.errors.append("Download was not completed") 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.""" """Download the content of a directory."""
contents: list[FileInformation] | None = None
if self.hacs.configuration.experimental: if self.hacs.configuration.experimental:
if ( if (
not self.repository_manifest.zip_release not self.repository_manifest.zip_release
@@ -631,9 +630,15 @@ class HacsRepository:
except HacsException as exception: except HacsException as exception:
self.logger.exception(exception) self.logger.exception(exception)
contents = self.gather_files_to_download()
if self.repository_manifest.filename: if self.repository_manifest.filename:
self.logger.debug("%s %s", self.string, 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: if not contents:
raise HacsException("No content to download") raise HacsException("No content to download")
@@ -654,14 +659,16 @@ class HacsRepository:
if not ref: if not ref:
raise HacsException("Missing required elements.") raise HacsException("Missing required elements.")
url = f"{BASE_API_URL}/repos/{self.data.full_name}/zipball/{ref}"
filecontent = await self.hacs.async_download_file( filecontent = await self.hacs.async_download_file(
url, github_archive(repository=self.data.full_name, version=ref, variant="tags"),
headers={ keep_url=True,
"Authorization": f"token {self.hacs.configuration.token}", nolog=True,
"User-Agent": f"HACS/{self.hacs.version}", )
},
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: if filecontent is None:
raise HacsException(f"[{self}] Failed to download zipball") raise HacsException(f"[{self}] Failed to download zipball")
@@ -681,8 +688,13 @@ class HacsRepository:
and filename != self.content.path.remote and filename != self.content.path.remote
): ):
path.filename = filename.replace(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) extractable.append(path)
if len(extractable) == 0:
raise HacsException("No content to extract")
zip_file.extractall(self.content.path.local, extractable) zip_file.extractall(self.content.path.local, extractable)
def cleanup_temp_dir(): def cleanup_temp_dir():
@@ -732,25 +744,7 @@ class HacsRepository:
if not info_files: if not info_files:
return "" return ""
try: return await self.get_documentation(filename=info_files[0]) or ""
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("<svg", "<disabled")
.replace("</svg", "</disabled"),
self,
)
except BaseException as exc: # lgtm [py/catch-base-exception] pylint: disable=broad-except
self.logger.error("%s %s", self.string, exc)
return ""
def remove(self) -> None: def remove(self) -> None:
"""Run remove tasks.""" """Run remove tasks."""
@@ -799,7 +793,7 @@ class HacsRepository:
try: try:
if self.data.category == "python_script": 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": elif self.data.category == "template":
local_path = f"{self.content.path.local}/{self.data.file_name}" local_path = f"{self.content.path.local}/{self.data.file_name}"
elif self.data.category == "theme": elif self.data.category == "theme":
@@ -888,7 +882,7 @@ class HacsRepository:
await self.async_pre_install() await self.async_pre_install()
self.logger.info("%s Pre installation steps completed", self.string) 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.""" """Run install steps."""
await self._async_pre_install() await self._async_pre_install()
self.hacs.async_dispatch( self.hacs.async_dispatch(
@@ -896,7 +890,7 @@ class HacsRepository:
{"repository": self.data.full_name, "progress": 30}, {"repository": self.data.full_name, "progress": 30},
) )
self.logger.info("%s Running installation steps", self.string) 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( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
{"repository": self.data.full_name, "progress": 90}, {"repository": self.data.full_name, "progress": 90},
@@ -927,10 +921,10 @@ class HacsRepository:
) )
self.logger.info("%s Post installation steps completed", self.string) 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.""" """Common installation steps of the repository."""
persistent_directory = None persistent_directory = None
await self.update_repository(force=True) await self.update_repository(force=version is None)
if self.content.path.local is None: if self.content.path.local is None:
raise HacsException("repository.content.path.local is None") raise HacsException("repository.content.path.local is None")
self.validate.errors.clear() self.validate.errors.clear()
@@ -938,11 +932,11 @@ class HacsRepository:
if not self.can_download: if not self.can_download:
raise HacsException("The version of Home Assistant is not compatible with this version") raise HacsException("The version of Home Assistant is not compatible with this version")
version = self.version_to_download() version_to_install = version or self.version_to_download()
if version == self.data.default_branch: if version_to_install == self.data.default_branch:
self.ref = version self.ref = version_to_install
else: else:
self.ref = f"tags/{version}" self.ref = f"tags/{version_to_install}"
self.hacs.async_dispatch( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, 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 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 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( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
{"repository": self.data.full_name, "progress": 50}, {"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) await self.download_zip_files(self.validate)
else: else:
await self.download_content() await self.download_content(version_to_install)
self.hacs.async_dispatch( self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
@@ -1010,10 +1005,10 @@ class HacsRepository:
self.data.installed = True self.data.installed = True
self.data.installed_commit = self.data.last_commit 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 self.data.installed_version = None
else: else:
self.data.installed_version = version self.data.installed_version = version_to_install
async def async_get_legacy_repository_object( async def async_get_legacy_repository_object(
self, self,
@@ -1228,6 +1223,25 @@ class HacsRepository:
files.append(FileInformation(path.download_url, path.full_path, path.filename)) files.append(FileInformation(path.download_url, path.full_path, path.filename))
return files 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) @concurrent(concurrenttasks=10)
async def dowload_repository_content(self, content: FileInformation) -> None: async def dowload_repository_content(self, content: FileInformation) -> None:
"""Download content.""" """Download content."""
@@ -1303,3 +1317,58 @@ class HacsRepository:
return self.data.selected_tag return self.data.selected_tag
return self.data.default_branch or "main" 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("<svg", "<disabled")
.replace("</svg", "</disabled"),
self,
)
if result
else None
)
async def get_hacs_json(self, *, version: str, **kwargs) -> 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

View File

@@ -3,14 +3,16 @@ from __future__ import annotations
from typing import Any from typing import Any
from homeassistant.components.update import UpdateEntity from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import callback from homeassistant.core import HomeAssistantError, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .base import HacsBase from .base import HacsBase
from .const import DOMAIN from .const import DOMAIN
from .entity import HacsRepositoryEntity from .entity import HacsRepositoryEntity
from .enums import HacsCategory, HacsDispatchEvent 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): 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): class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
"""Update entities for repositories downloaded with HACS.""" """Update entities for repositories downloaded with HACS."""
@property _attr_supported_features = (
def supported_features(self) -> int | None: UpdateEntityFeature.INSTALL
"""Return the supported features of the entity.""" | UpdateEntityFeature.SPECIFIC_VERSION
features = 4 | 16 | UpdateEntityFeature.PROGRESS
if self.repository.can_download: | UpdateEntityFeature.RELEASE_NOTES
features = features | 1 )
return features
@property @property
def name(self) -> str | None: def name(self) -> str | None:
@@ -75,14 +76,59 @@ class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
return f"https://brands.home-assistant.io/_/{self.repository.data.domain}/icon.png" 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: async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
"""Install an update.""" """Install an update."""
await self._ensure_capabilities(version)
self.repository.logger.info("Starting update, %s", version)
if self.repository.display_version_or_commit == "version": if self.repository.display_version_or_commit == "version":
self._update_in_progress(progress=10) self._update_in_progress(progress=10)
self.repository.data.selected_tag = self.latest_version if not version:
await self.repository.update_repository(force=True) 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) self._update_in_progress(progress=20)
await self.repository.async_install()
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) self._update_in_progress(progress=False)
async def async_release_notes(self) -> str | None: async def async_release_notes(self) -> str | None:

View File

@@ -45,9 +45,9 @@ HACS_MANIFEST_JSON_SCHEMA = vol.Schema(
vol.Optional("content_in_root"): bool, vol.Optional("content_in_root"): bool,
vol.Optional("country"): _country_validator, vol.Optional("country"): _country_validator,
vol.Optional("filename"): str, vol.Optional("filename"): str,
vol.Optional("hacs"): vol.Coerce(AwesomeVersion), vol.Optional("hacs"): str,
vol.Optional("hide_default_branch"): bool, vol.Optional("hide_default_branch"): bool,
vol.Optional("homeassistant"): vol.Coerce(AwesomeVersion), vol.Optional("homeassistant"): str,
vol.Optional("persistent_directory"): str, vol.Optional("persistent_directory"): str,
vol.Optional("render_readme"): bool, vol.Optional("render_readme"): bool,
vol.Optional("zip_release"): bool, vol.Optional("zip_release"): bool,

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from voluptuous.error import Invalid from voluptuous.error import Invalid
from voluptuous.humanize import humanize_error
from ..enums import RepositoryFile from ..enums import HacsCategory, RepositoryFile
from ..repositories.base import HacsRepository from ..repositories.base import HacsManifest, HacsRepository
from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA
from .base import ActionValidationBase, ValidationException from .base import ActionValidationBase, ValidationException
@@ -25,6 +26,10 @@ class Validator(ActionValidationBase):
content = await self.repository.async_get_hacs_json(self.repository.ref) content = await self.repository.async_get_hacs_json(self.repository.ref)
try: try:
HACS_MANIFEST_JSON_SCHEMA(content) hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(content))
except Invalid as exception: 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")