Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow requesting tags and branches in update entities #3301

Merged
merged 6 commits into from
Oct 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions custom_components/hacs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
115 changes: 80 additions & 35 deletions custom_components/hacs/repositories/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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")

Expand Down Expand Up @@ -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:
Expand All @@ -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():
Expand Down Expand Up @@ -897,15 +942,15 @@ 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(
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
{"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},
Expand Down Expand Up @@ -936,22 +981,22 @@ 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()

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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions custom_components/hacs/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Custom HACS types."""

from typing import TypedDict


class DownloadableContent(TypedDict):
"""Downloadable content."""

url: str
name: str
39 changes: 28 additions & 11 deletions custom_components/hacs/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions custom_components/hacs/utils/url.py
Original file line number Diff line number Diff line change
@@ -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"