diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 00000000000..6d3cf0b1ed7 --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,40 @@ +# Add Multilingual README Support + +## Summary + +This PR adds support for multilingual README files in HACS. Users will automatically see content in their Home Assistant language setting if available, with fallback to default English content. + +## Changes + +1. **Multilingual README Support** + - Added `async_get_info_file_contents_with_language()` method + - Supports `README.{language_code}.md` files (e.g., `README.de.md`, `README.fr.md`) + - Automatic language detection with fallback to `README.md` + +2. **Manifest Updates** + - Renamed `supported_languages` to `content_languages` in `hacs.json` manifest + - Updated validator to use `content_languages` key + - Validates language codes and checks for corresponding README files + +3. **WebSocket Handler Updates** + - Extended `hacs/repository/info` to use language parameter for README content + +## Related PRs + +- **Frontend PR:** https://github.com/hacs/frontend/pull/XXX +- **Documentation PR:** https://github.com/hacs/documentation/pull/660 + +## Checklist + +- [x] Code follows project style guidelines +- [x] Changes are backward compatible +- [x] Code tested locally +- [x] Validators updated +- [x] WebSocket handlers updated + +## Notes + +- Repository maintainers can provide multilingual README files using `README.{language_code}.md` (e.g., `README.de.md`, `README.fr.md`) +- Language codes must be 2-letter ISO 639-1 codes (e.g., `de`, `fr`, `es`) +- The `content_languages` key in `hacs.json` can optionally declare supported languages for validation + diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index 950f52facd3..0833ba958bd 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -1,1460 +1,1551 @@ -"""Repository.""" - -from __future__ import annotations - -from asyncio import sleep -from datetime import UTC, datetime -import os -import pathlib -import shutil -import tempfile -from typing import TYPE_CHECKING, Any -import zipfile - -from aiogithubapi import ( - AIOGitHubAPIException, - AIOGitHubAPINotModifiedException, - GitHubReleaseModel, -) -from aiogithubapi.objects.repository import AIOGitHubAPIRepository -import attr -from homeassistant.helpers import device_registry as dr, issue_registry as ir - -from ..const import DOMAIN -from ..enums import HacsDispatchEvent, RepositoryFile -from ..exceptions import ( - HacsException, - HacsNotModifiedException, - HacsRepositoryArchivedException, - HacsRepositoryExistException, -) -from ..types import DownloadableContent -from ..utils.backup import Backup -from ..utils.decode import decode_content -from ..utils.decorator import concurrent, return_none_on_exception -from ..utils.file_system import async_exists, async_remove, async_remove_directory -from ..utils.filters import filter_content_return_one_of_type -from ..utils.github_graphql_query import GET_REPOSITORY_RELEASES -from ..utils.json import json_loads -from ..utils.logger import LOGGER -from ..utils.path import is_safe -from ..utils.queue_manager import QueueManager -from ..utils.store import async_remove_store -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, - version_left_higher_then_right, -) -from ..utils.workarounds import DOMAIN_OVERRIDES - -if TYPE_CHECKING: - from ..base import HacsBase - - -TOPIC_FILTER = ( - "add-on", - "addon", - "app", - "appdaemon-apps", - "appdaemon", - "custom-card", - "custom-cards", - "custom-component", - "custom-components", - "customcomponents", - "hacktoberfest", - "hacs-default", - "hacs-integration", - "hacs-repository", - "hacs", - "hass", - "hassio", - "home-assistant-custom", - "home-assistant-frontend", - "home-assistant-hacs", - "home-assistant-sensor", - "home-assistant", - "home-automation", - "homeassistant-components", - "homeassistant-integration", - "homeassistant-sensor", - "homeassistant", - "homeautomation", - "integration", - "lovelace-ui", - "lovelace", - "media-player", - "mediaplayer", - "plugin", - "python_script", - "python-script", - "python", - "sensor", - "smart-home", - "smarthome", - "template", - "templates", - "theme", - "themes", -) - - -REPOSITORY_KEYS_TO_EXPORT = ( - # Keys can not be removed from this list until v3 - # If keys are added, the action need to be re-run with force - ("description", ""), - ("downloads", 0), - ("domain", None), - ("etag_releases", None), - ("etag_repository", None), - ("full_name", ""), - ("last_commit", None), - ("last_updated", 0), - ("last_version", None), - ("manifest_name", None), - ("open_issues", 0), - ("prerelease", None), - ("stargazers_count", 0), - ("topics", []), -) - -HACS_MANIFEST_KEYS_TO_EXPORT = ( - # Keys can not be removed from this list until v3 - # If keys are added, the action need to be re-run with force - ("country", []), - ("name", None), -) - - -class FileInformation: - """FileInformation.""" - - def __init__(self, url, path, name): - self.download_url = url - self.path = path - self.name = name - - -@attr.s(auto_attribs=True) -class RepositoryData: - """RepositoryData class.""" - - archived: bool = False - authors: list[str] = [] - category: str = "" - config_flow: bool = False - default_branch: str = None - description: str = "" - domain: str = None - downloads: int = 0 - etag_repository: str = None - etag_releases: str = None - file_name: str = "" - first_install: bool = False - full_name: str = "" - hide: bool = False - has_issues: bool = True - id: int = 0 - installed_commit: str = None - installed_version: str = None - installed: bool = False - last_commit: str = None - last_fetched: datetime = None - last_updated: str = 0 - last_version: str = None - manifest_name: str = None - new: bool = True - open_issues: int = 0 - prerelease: str = None - published_tags: list[str] = [] - releases: bool = False - selected_tag: str = None - show_beta: bool = False - stargazers_count: int = 0 - topics: list[str] = [] - - @property - def name(self): - """Return the name.""" - if self.category == "integration": - return self.domain - return self.full_name.split("/")[-1] - - def to_json(self): - """Export to json.""" - return attr.asdict(self, filter=lambda attr, value: attr.name != "last_fetched") - - @staticmethod - def create_from_dict(source: dict, action: bool = False) -> RepositoryData: - """Set attributes from dicts.""" - data = RepositoryData() - data.update_data(source, action) - return data - - def update_data(self, data: dict, action: bool = False) -> None: - """Update data of the repository.""" - for key, value in data.items(): - if key not in self.__dict__: - continue - - if key == "last_fetched" and isinstance(value, float): - setattr(self, key, datetime.fromtimestamp(value, UTC)) - elif key == "id": - setattr(self, key, str(value)) - elif key == "country": - if isinstance(value, str): - setattr(self, key, [value]) - else: - setattr(self, key, value) - elif key == "topics" and not action: - setattr(self, key, [topic for topic in value if topic not in TOPIC_FILTER]) - - else: - setattr(self, key, value) - - -@attr.s(auto_attribs=True) -class HacsManifest: - """HacsManifest class.""" - - content_in_root: bool = False - country: list[str] = [] - filename: str = None - hacs: str = None # Minimum HACS version - hide_default_branch: bool = False - homeassistant: str = None # Minimum Home Assistant version - manifest: dict = {} - name: str = None - persistent_directory: str = None - render_readme: bool = False - zip_release: bool = False - - def to_dict(self): - """Export to json.""" - return attr.asdict(self) - - @staticmethod - def from_dict(manifest: dict): - """Set attributes from dicts.""" - if manifest is None: - raise HacsException("Missing manifest data") - - manifest_data = HacsManifest() - manifest_data.manifest = { - k: v - for k, v in manifest.items() - if k in manifest_data.__dict__ and v != manifest_data.__getattribute__(k) - } - - for key, value in manifest_data.manifest.items(): - if key == "country" and isinstance(value, str): - setattr(manifest_data, key, [value]) - elif key in manifest_data.__dict__: - setattr(manifest_data, key, value) - return manifest_data - - def update_data(self, data: dict) -> None: - """Update the manifest data.""" - for key, value in data.items(): - if key not in self.__dict__: - continue - - if key == "country": - if isinstance(value, str): - setattr(self, key, [value]) - else: - setattr(self, key, value) - else: - setattr(self, key, value) - - -class RepositoryReleases: - """RepositoyReleases.""" - - last_release = None - last_release_object = None - published_tags = [] - objects: list[GitHubReleaseModel] = [] - releases = False - downloads = None - - -class RepositoryPath: - """RepositoryPath.""" - - local: str | None = None - remote: str | None = None - - -class RepositoryContent: - """RepositoryContent.""" - - path: RepositoryPath | None = None - files = [] - objects = [] - single = False - - -class HacsRepository: - """HacsRepository.""" - - def __init__(self, hacs: HacsBase) -> None: - """Set up HacsRepository.""" - self.hacs = hacs - self.additional_info = "" - self.data = RepositoryData() - self.content = RepositoryContent() - self.content.path = RepositoryPath() - self.repository_object: AIOGitHubAPIRepository | None = None - self.updated_info = False - self.state = None - self.force_branch = False - self.integration_manifest = {} - self.repository_manifest = HacsManifest.from_dict({}) - self.validate = Validate() - self.releases = RepositoryReleases() - self.pending_restart = False - self.tree = [] - self.treefiles = [] - self.ref = None - self.logger = LOGGER - - def __str__(self) -> str: - """Return a string representation of the repository.""" - return self.string - - @property - def string(self) -> str: - """Return a string representation of the repository.""" - return f"<{self.data.category.title()} {self.data.full_name}>" - - @property - def display_name(self) -> str: - """Return display name.""" - if self.repository_manifest.name is not None: - return self.repository_manifest.name - - if self.data.category == "integration": - if self.data.manifest_name is not None: - return self.data.manifest_name - if "name" in self.integration_manifest: - return self.integration_manifest["name"] - - return self.data.full_name.split("/")[-1].replace("-", " ").replace("_", " ").title() - - @property - def ignored_by_country_configuration(self) -> bool: - """Return True if hidden by country.""" - if self.data.installed: - return False - configuration = self.hacs.configuration.country.lower() - if configuration == "all": - return False - - manifest = [entry.lower() for entry in self.repository_manifest.country or []] - if not manifest: - return False - return configuration not in manifest - - @property - def display_status(self) -> str: - """Return display_status.""" - if self.data.new: - status = "new" - elif self.pending_restart: - status = "pending-restart" - elif self.pending_update: - status = "pending-upgrade" - elif self.data.installed: - status = "installed" - else: - status = "default" - return status - - @property - def display_installed_version(self) -> str: - """Return display_authors""" - if self.data.installed_version is not None: - installed = self.data.installed_version - else: - if self.data.installed_commit is not None: - installed = self.data.installed_commit - else: - installed = "" - return str(installed) - - @property - def display_available_version(self) -> str: - """Return display_authors""" - if self.data.show_beta and self.data.prerelease is not None: - available = self.data.prerelease - elif self.data.last_version is not None: - available = self.data.last_version - else: - if self.data.last_commit is not None: - available = self.data.last_commit - else: - available = "" - return str(available) - - @property - def display_version_or_commit(self) -> str: - """Does the repositoriy use releases or commits?""" - if self.data.releases: - version_or_commit = "version" - else: - version_or_commit = "commit" - return version_or_commit - - @property - def pending_update(self) -> bool: - """Return True if pending update.""" - if self.data.installed: - if self.data.selected_tag is not None: - if self.data.selected_tag == self.data.default_branch: - if self.data.installed_commit != self.data.last_commit: - return True - return False - if self.display_version_or_commit == "version": - if ( - result := version_left_higher_then_right( - self.display_available_version, - self.display_installed_version, - ) - ) is not None: - return result - if self.display_installed_version != self.display_available_version: - return True - - return False - - @property - def can_download(self) -> bool: - """Return True if we can download.""" - if self.repository_manifest.homeassistant is not None: - if self.data.releases: - if not version_left_higher_or_equal_then_right( - self.hacs.core.ha_version.string, - self.repository_manifest.homeassistant, - ): - return False - return True - - @property - def localpath(self) -> str | None: - """Return localpath.""" - return None - - @property - def should_try_releases(self) -> bool: - """Return a boolean indicating whether to download releases or not.""" - if self.repository_manifest.zip_release: - if self.repository_manifest.filename.endswith(".zip"): - if self.ref != self.data.default_branch: - return True - if self.ref == self.data.default_branch: - return False - if self.data.category not in ["plugin", "theme"]: - return False - if not self.data.releases: - return False - return True - - async def validate_repository(self) -> None: - """Validate.""" - - @concurrent(concurrenttasks=10, backoff_time=5) - async def update_repository(self, ignore_issues=False, force=False) -> None: - """Update the repository""" - - async def common_validate(self, ignore_issues: bool = False) -> None: - """Common validation steps of the repository.""" - self.validate.errors.clear() - - # Make sure the repository exist. - self.logger.debug("%s Checking repository.", self.string) - await self.common_update_data(ignore_issues=ignore_issues) - - # Get the content of hacs.json - if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: - if manifest := await self.async_get_hacs_json(): - self.repository_manifest = HacsManifest.from_dict(manifest) - self.data.update_data( - self.repository_manifest.to_dict(), - action=self.hacs.system.action, - ) - - async def common_registration(self) -> None: - """Common registration steps of the repository.""" - # Attach repository - if self.repository_object is None: - try: - self.repository_object, etag = await self.async_get_legacy_repository_object( - etag=None if self.data.installed else self.data.etag_repository, - ) - self.data.update_data( - self.repository_object.attributes, - action=self.hacs.system.action, - ) - self.data.etag_repository = etag - except HacsNotModifiedException: - self.logger.debug("%s Did not update, content was not modified", self.string) - return - - if self.repository_object: - self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) - self.data.last_fetched = datetime.now(UTC) - - @concurrent(concurrenttasks=10, backoff_time=5) - async def common_update(self, ignore_issues=False, force=False, skip_releases=False) -> bool: - """Common information update steps of the repository.""" - self.logger.debug("%s Getting repository information", self.string) - - # Attach repository - current_etag = self.data.etag_repository - try: - await self.common_update_data( - ignore_issues=ignore_issues, - force=force, - skip_releases=skip_releases, - ) - except HacsRepositoryExistException: - self.data.full_name = self.hacs.common.renamed_repositories[self.data.full_name] - await self.common_update_data(ignore_issues=ignore_issues, force=force) - - except HacsException: - if not ignore_issues and not force: - return False - - if not self.data.installed and (current_etag == self.data.etag_repository) and not force: - self.logger.debug("%s Did not update, content was not modified", self.string) - return False - - # Update last updated - if self.repository_object: - self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) - - # Update last available commit - await self.repository_object.set_last_commit() - self.data.last_commit = self.repository_object.last_commit - - # Get the content of hacs.json - if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: - if manifest := await self.async_get_hacs_json(): - self.repository_manifest = HacsManifest.from_dict(manifest) - self.data.update_data( - self.repository_manifest.to_dict(), - action=self.hacs.system.action, - ) - - # Update "info.md" - self.additional_info = await self.async_get_info_file_contents() - - # Set last fetch attribute - self.data.last_fetched = datetime.now(UTC) - - return True - - async def download_zip_files(self, validate: Validate) -> None: - """Download ZIP archive from repository release.""" - - try: - 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, - ) - # lgtm [py/catch-base-exception] pylint: disable=broad-except - except BaseException: - validate.errors.append( - f"Download of {self.repository_manifest.filename} was not completed" - ) - - 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["url"]) - - if filecontent is None: - validate.errors.append(f"Failed to download {content['url']}") - return - - temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) - temp_file = f"{temp_dir}/{self.repository_manifest.filename}" - - result = await self.hacs.async_save_file(temp_file, filecontent) - - def _extract_zip_file(): - with zipfile.ZipFile(temp_file, "r") as zip_file: - zip_file.extractall(self.content.path.local) - - await self.hacs.hass.async_add_executor_job(_extract_zip_file) - - def cleanup_temp_dir(): - """Cleanup temp_dir.""" - if os.path.exists(temp_dir): - self.logger.debug("%s Cleaning up %s", self.string, temp_dir) - shutil.rmtree(temp_dir) - - if result: - 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") - # lgtm [py/catch-base-exception] pylint: disable=broad-except - except BaseException: - validate.errors.append("Download was not completed") - - async def download_content(self, version: string | None = None) -> None: - """Download the content of a directory.""" - contents: list[FileInformation] | None = None - if ( - not self.repository_manifest.zip_release - and not self.data.file_name - and self.content.path.remote is not None - ): - self.logger.info("%s Downloading repository archive", self.string) - try: - await self.download_repository_zip() - return - except HacsException as exception: - self.logger.exception(exception) - - 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") - - download_queue = QueueManager(hass=self.hacs.hass) - - for content in contents: - if self.repository_manifest.content_in_root and self.repository_manifest.filename: - if content.name != self.repository_manifest.filename: - continue - download_queue.add(self.dowload_repository_content(content)) - - await download_queue.execute() - - async def download_repository_zip(self): - """Download the zip archive of the repository.""" - ref = f"{self.ref}".replace("tags/", "") - - if not ref: - raise HacsException("Missing required elements.") - - filecontent = await self.hacs.async_download_file( - 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") - - temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) - temp_file = f"{temp_dir}/{self.repository_manifest.filename}" - result = await self.hacs.async_save_file(temp_file, filecontent) - if not result: - raise HacsException("Could not save ZIP file") - - def _extract_zip_file(): - with zipfile.ZipFile(temp_file, "r") as zip_file: - extractable = [] - for path in zip_file.filelist: - filename = "/".join(path.filename.split("/")[1:]) - if ( - filename.startswith(self.content.path.remote) - 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) - - await self.hacs.hass.async_add_executor_job(_extract_zip_file) - - def cleanup_temp_dir(): - """Cleanup temp_dir.""" - if os.path.exists(temp_dir): - self.logger.debug("%s Cleaning up %s", self.string, temp_dir) - shutil.rmtree(temp_dir) - - await self.hacs.hass.async_add_executor_job(cleanup_temp_dir) - self.logger.info("%s Content was extracted to %s", self.string, self.content.path.local) - - async def async_get_hacs_json(self, ref: str = None) -> dict[str, Any] | None: - """Get the content of the hacs.json file.""" - 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=RepositoryFile.HACS_JSON, - **{"params": {"ref": ref or self.version_to_download()}}, - ) - if response: - return json_loads(decode_content(response.data.content)) - # lgtm [py/catch-base-exception] pylint: disable=broad-except - except BaseException: - pass - - async def async_get_info_file_contents(self, *, version: str | None = None, **kwargs) -> str: - """Get the content of the info.md file.""" - - def _info_file_variants() -> tuple[str, ...]: - name: str = "readme" - return ( - f"{name.upper()}.md", - f"{name}.md", - f"{name}.MD", - f"{name.upper()}.MD", - name.upper(), - name, - ) - - info_files = [filename for filename in _info_file_variants() if filename in self.treefiles] - - if not info_files: - return "" - - return await self.get_documentation(filename=info_files[0], version=version) or "" - - def remove(self) -> None: - """Run remove tasks.""" - if self.hacs.repositories.is_registered(repository_id=str(self.data.id)): - self.logger.info("%s Starting removal", self.string) - self.hacs.repositories.unregister(self) - - async def uninstall(self) -> None: - """Run uninstall tasks.""" - self.logger.info("%s Removing", self.string) - if not await self.remove_local_directory(): - raise HacsException("Could not uninstall") - self.data.installed = False - await self._async_post_uninstall() - await async_remove_store(self.hacs.hass, f"hacs/{self.data.id}.hacs") - - self.data.installed_version = None - self.data.installed_commit = None - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY, - { - "id": 1337, - "action": "uninstall", - "repository": self.data.full_name, - "repository_id": self.data.id, - }, - ) - - await self.async_remove_entity_device() - ir.async_delete_issue(self.hacs.hass, DOMAIN, f"removed_{self.data.id}") - - async def remove_local_directory(self) -> None: - """Check the local directory.""" - - try: - if self.data.category == "python_script": - 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": - path = ( - f"{self.hacs.core.config_path}/" - f"{self.hacs.configuration.theme_path}/" - f"{self.data.name}.yaml" - ) - await async_remove(self.hacs.hass, path, missing_ok=True) - local_path = self.content.path.local - elif self.data.category == "integration": - if not self.data.domain: - if domain := DOMAIN_OVERRIDES.get(self.data.full_name): - self.data.domain = domain - self.content.path.local = self.localpath - else: - self.logger.error("%s Missing domain", self.string) - return False - local_path = self.content.path.local - else: - local_path = self.content.path.local - - if await async_exists(self.hacs.hass, local_path): - if not is_safe(self.hacs, local_path): - self.logger.error("%s Path %s is blocked from removal", self.string, local_path) - return False - self.logger.debug("%s Removing %s", self.string, local_path) - - if self.data.category in ["python_script", "template"]: - await async_remove(self.hacs.hass, local_path) - else: - await async_remove_directory(self.hacs.hass, local_path) - - while await async_exists(self.hacs.hass, local_path): - await sleep(1) - else: - self.logger.debug( - "%s Presumed local content path %s does not exist", self.string, local_path - ) - - except ( - # lgtm [py/catch-base-exception] pylint: disable=broad-except - BaseException - ) as exception: - self.logger.debug("%s Removing %s failed with %s", self.string, local_path, exception) - return False - return True - - async def async_pre_registration(self) -> None: - """Run pre registration steps.""" - - @concurrent(concurrenttasks=10) - async def async_registration(self, ref=None) -> None: - """Run registration steps.""" - await self.async_pre_registration() - - if ref is not None: - self.data.selected_tag = ref - self.ref = ref - self.force_branch = True - - if not await self.validate_repository(): - return False - - # Run common registration steps. - await self.common_registration() - - # Set correct local path - self.content.path.local = self.localpath - - # Run local post registration steps. - await self.async_post_registration() - - async def async_post_registration(self) -> None: - """Run post registration steps.""" - if not self.hacs.system.action: - return - await self.hacs.validation.async_run_repository_checks(self) - - async def async_pre_install(self) -> None: - """Run pre install steps.""" - - async def _async_pre_install(self) -> None: - """Run pre install steps.""" - self.logger.info("%s Running pre installation steps", self.string) - await self.async_pre_install() - self.logger.info("%s Pre installation steps completed", self.string) - - 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(version=version) - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 90}, - ) - self.logger.info("%s Installation steps completed", self.string) - await self._async_post_install() - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": False}, - ) - - async def async_post_installation(self) -> None: - """Run post install steps.""" - - async def async_post_uninstall(self): - """Run post uninstall steps.""" - - async def _async_post_uninstall(self): - """Run post uninstall steps.""" - await self.async_post_uninstall() - - async def _async_post_install(self) -> None: - """Run post install steps.""" - self.logger.info("%s Running post installation steps", self.string) - await self.async_post_installation() - self.data.new = False - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY, - { - "id": 1337, - "action": "install", - "repository": self.data.full_name, - "repository_id": self.data.id, - }, - ) - self.logger.info("%s Post installation steps completed", self.string) - - async def async_install_repository(self, *, version: str | None = None, **_) -> None: - """Common installation steps of the repository.""" - persistent_directory = None - force_update = version is None or ( - self.data.last_version is not None and version != self.data.last_version - ) - await self.update_repository(force=force_update) - if self.content.path.local is None: - raise HacsException("repository.content.path.local is None") - self.validate.errors.clear() - - 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_to_install}" - - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 40}, - ) - - if self.repository_manifest.persistent_directory: - if await async_exists( - self.hacs.hass, - f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", - ): - persistent_directory = Backup( - hacs=self.hacs, - local_path=f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", - backup_path=tempfile.gettempdir() + "/hacs_persistent_directory/", - ) - await self.hacs.hass.async_add_executor_job(persistent_directory.create) - - if self.data.installed and not self.content.single: - backup = Backup(hacs=self.hacs, local_path=self.content.path.local) - await self.hacs.hass.async_add_executor_job(backup.create) - - 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 self.repository_manifest.filename: - await self.download_zip_files(self.validate) - else: - await self.download_content(version_to_install) - - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 70}, - ) - - if self.validate.errors: - for error in self.validate.errors: - self.logger.error("%s %s", self.string, error) - if self.data.installed and not self.content.single: - await self.hacs.hass.async_add_executor_job(backup.restore) - await self.hacs.hass.async_add_executor_job(backup.cleanup) - raise HacsException("Could not download, see log for details") - - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 80}, - ) - - if self.data.installed and not self.content.single: - await self.hacs.hass.async_add_executor_job(backup.cleanup) - - if persistent_directory is not None: - await self.hacs.hass.async_add_executor_job(persistent_directory.restore) - await self.hacs.hass.async_add_executor_job(persistent_directory.cleanup) - - if self.validate.success: - self.data.installed = True - self.data.installed_commit = self.data.last_commit - - if version_to_install == self.data.default_branch: - self.data.installed_version = None - else: - self.data.installed_version = version_to_install - - async def async_get_legacy_repository_object( - self, - etag: str | None = None, - ) -> tuple[AIOGitHubAPIRepository, Any | None]: - """Return a repository object.""" - try: - repository = await self.hacs.github.get_repo(self.data.full_name, etag) - return repository, self.hacs.github.client.last_response.etag - except AIOGitHubAPINotModifiedException as exception: - raise HacsNotModifiedException(exception) from exception - except (ValueError, AIOGitHubAPIException, Exception) as exception: - raise HacsException(exception) from exception - - def update_filenames(self) -> None: - """Get the filename to target.""" - - async def get_tree(self, ref: str): - """Return the repository tree.""" - if self.repository_object is None: - raise HacsException("No repository_object") - try: - tree = await self.repository_object.get_tree(ref) - return tree - except (ValueError, AIOGitHubAPIException) as exception: - raise HacsException(exception) from exception - - async def get_releases(self, prerelease=False, returnlimit=5) -> list[GitHubReleaseModel]: - """Return the repository releases.""" - response = await self.hacs.async_github_api_method( - method=self.hacs.githubapi.repos.releases.list, - repository=self.data.full_name, - ) - releases = [] - for release in response.data or []: - if len(releases) == returnlimit: - break - if release.draft or (release.prerelease and not prerelease): - continue - releases.append(release) - return releases - - async def common_update_data( - self, - ignore_issues: bool = False, - force: bool = False, - retry=False, - skip_releases=False, - ) -> None: - """Common update data.""" - releases = [] - try: - repository_object, etag = await self.async_get_legacy_repository_object( - etag=None if force or self.data.installed else self.data.etag_repository, - ) - self.repository_object = repository_object - if self.data.full_name.lower() != repository_object.full_name.lower(): - self.hacs.common.renamed_repositories[self.data.full_name] = ( - repository_object.full_name - ) - if not self.hacs.system.generator: - raise HacsRepositoryExistException - self.logger.error( - "%s Repository has been renamed - %s", self.string, repository_object.full_name - ) - self.data.update_data( - repository_object.attributes, - action=self.hacs.system.action, - ) - self.data.etag_repository = etag - except HacsNotModifiedException: - return - except HacsRepositoryExistException: - raise HacsRepositoryExistException from None - except (AIOGitHubAPIException, HacsException) as exception: - if not self.hacs.status.startup or self.hacs.system.generator: - self.logger.error("%s %s", self.string, exception) - if not ignore_issues: - self.validate.errors.append("Repository does not exist.") - raise HacsException(exception) from exception - - # Make sure the repository is not archived. - if self.data.archived and not ignore_issues: - self.validate.errors.append("Repository is archived.") - if self.data.full_name not in self.hacs.common.archived_repositories: - self.hacs.common.archived_repositories.add(self.data.full_name) - raise HacsRepositoryArchivedException(f"{self} Repository is archived.") - - # Make sure the repository is not in the blacklist. - if self.hacs.repositories.is_removed(self.data.full_name): - removed = self.hacs.repositories.removed_repository(self.data.full_name) - if removed.removal_type != "remove" and not ignore_issues: - self.validate.errors.append("Repository has been requested to be removed.") - raise HacsException(f"{self} Repository has been requested to be removed.") - - # Get releases. - if not skip_releases: - try: - releases = await self.get_releases(prerelease=True, returnlimit=30) - if releases: - self.data.prerelease = None - for release in releases: - if release.draft: - continue - elif release.prerelease: - if self.data.prerelease is None: - self.data.prerelease = release.tag_name - else: - self.data.last_version = release.tag_name - break - - self.data.releases = True - - filtered_releases = [ - release - for release in releases - if not release.draft and (self.data.show_beta or not release.prerelease) - ] - self.releases.objects = filtered_releases - self.data.published_tags = [x.tag_name for x in filtered_releases] - - except HacsException: - self.data.releases = False - - if not self.force_branch: - self.ref = self.version_to_download() - if self.data.releases: - for release in self.releases.objects or []: - if release.tag_name == self.ref: - if assets := release.assets: - downloads = next(iter(assets)).download_count - self.data.downloads = downloads - elif self.hacs.system.generator and self.repository_object: - await self.repository_object.set_last_commit() - self.data.last_commit = self.repository_object.last_commit - - self.hacs.log.debug( - "%s Running checks against %s", self.string, self.ref.replace("tags/", "") - ) - - try: - self.tree = await self.get_tree(self.ref) - if not self.tree: - raise HacsException("No files in tree") - self.treefiles = [] - for treefile in self.tree: - self.treefiles.append(treefile.full_path) - except (AIOGitHubAPIException, HacsException) as exception: - if ( - not retry - and self.ref is not None - and str(exception).startswith("GitHub returned 404") - ): - # Handle tags/branches being deleted. - self.data.selected_tag = None - self.ref = self.version_to_download() - self.logger.warning( - "%s Selected version/branch %s has been removed, falling back to default", - self.string, - self.ref, - ) - return await self.common_update_data(ignore_issues, force, True) - if not self.hacs.status.startup and not ignore_issues: - self.logger.error("%s %s", self.string, exception) - if not ignore_issues: - raise HacsException(exception) from None - - def gather_files_to_download(self) -> list[FileInformation]: - """Return a list of file objects to be downloaded.""" - files = [] - tree = self.tree - ref = f"{self.ref}".replace("tags/", "") - releaseobjects = self.releases.objects - category = self.data.category - remotelocation = self.content.path.remote - - if self.should_try_releases: - for release in releaseobjects or []: - if ref == release.tag_name: - for asset in release.assets or []: - files.append( - FileInformation(asset.browser_download_url, asset.name, asset.name) - ) - if files: - return files - - if self.content.single: - for treefile in tree: - if treefile.filename == self.data.file_name: - files.append( - FileInformation( - treefile.download_url, treefile.full_path, treefile.filename - ) - ) - return files - - if category == "plugin": - for treefile in tree: - if treefile.path in ["", "dist"]: - if remotelocation == "dist" and not treefile.filename.startswith("dist"): - continue - if not remotelocation: - if not treefile.filename.endswith(".js"): - continue - if treefile.path != "": - continue - if not treefile.is_directory: - files.append( - FileInformation( - treefile.download_url, treefile.full_path, treefile.filename - ) - ) - if files: - return files - - if self.repository_manifest.content_in_root: - if not self.repository_manifest.filename: - if category == "theme": - tree = filter_content_return_one_of_type(self.tree, "", "yaml", "full_path") - - for path in tree: - if path.is_directory: - continue - if path.full_path.startswith(self.content.path.remote): - 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.""" - try: - self.logger.debug("%s Downloading %s", self.string, content.name) - - filecontent = await self.hacs.async_download_file(content.download_url) - - if filecontent is None: - self.validate.errors.append(f"[{content.name}] was not downloaded.") - return - - # Save the content of the file. - if self.content.single or content.path is None: - local_directory = self.content.path.local - - else: - _content_path = content.path - if not self.repository_manifest.content_in_root: - _content_path = _content_path.replace(f"{self.content.path.remote}", "") - - local_directory = f"{self.content.path.local}/{_content_path}" - local_directory = local_directory.split("/") - del local_directory[-1] - local_directory = "/".join(local_directory) - - # Check local directory - pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True) - - local_file_path = (f"{local_directory}/{content.name}").replace("//", "/") - - result = await self.hacs.async_save_file(local_file_path, filecontent) - if result: - self.logger.info("%s Download of %s completed", self.string, content.name) - return - self.validate.errors.append(f"[{content.name}] was not downloaded.") - - except ( - # lgtm [py/catch-base-exception] pylint: disable=broad-except - BaseException - ) as exception: - self.validate.errors.append(f"Download was not completed [{exception}]") - - async def async_remove_entity_device(self) -> None: - """Remove the entity device.""" - device_registry: dr.DeviceRegistry = dr.async_get(hass=self.hacs.hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, str(self.data.id))}) - - if device is None: - return - - device_registry.async_remove_device(device_id=device.id) - - def version_to_download(self) -> str: - """Determine which version to download.""" - if self.force_branch and self.ref is not None: - return self.ref - - if self.data.last_version is not None: - if self.data.selected_tag is not None: - if self.data.selected_tag == self.data.last_version: - self.data.selected_tag = None - return self.data.last_version - return self.data.selected_tag - return self.data.last_version - - if self.data.selected_tag is not None: - if self.data.selected_tag == self.data.default_branch: - return self.data.default_branch - if self.data.selected_tag in self.data.published_tags: - return self.data.selected_tag - - return self.data.default_branch or "main" - - async def get_documentation( - self, - *, - filename: str | None = None, - version: str | None = None, - **kwargs, - ) -> str | None: - """Get the documentation of the repository.""" - if filename is None: - return None - - if version is not None: - target_version = version - elif self.data.installed: - target_version = self.data.installed_version or self.data.installed_commit - else: - target_version = 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, - target_version, - filename, - ) - if target_version is None: - return None - - result = await self.hacs.async_download_file( - f"https://raw.githubusercontent.com/{self.data.full_name}/{target_version}/{filename}", - nolog=True, - ) - - return ( - result.decode(encoding="utf-8") - .replace(" HacsManifest | None: - """Get the hacs.json file of the repository.""" - if (result := await self.get_hacs_json_raw(version=version)) is None: - return None - return HacsManifest.from_dict(result) - - @return_none_on_exception - async def get_hacs_json_raw( - self, - *, - version: str, - **kwargs, - ) -> dict[str, Any] | None: - """Get the hacs.json file of the repository.""" - self.logger.debug("%s Getting hacs.json for version=%s", self.string, version) - result = await self.hacs.async_download_file( - f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/hacs.json", - nolog=True, - handle_rate_limit=True, - ) - return json_loads(result) if result else None - - async def _ensure_download_capabilities(self, ref: str | None, **kwargs: Any) -> None: - """Ensure that the download can be handled.""" - target_manifest: HacsManifest | None = None - if ref is None: - if not self.can_download: - raise HacsException( - f"This {self.data.category.value} is not available for download." - ) - return - - if not ref: - target_manifest = self.repository_manifest - else: - target_manifest = await self.get_hacs_json(version=ref) - - if target_manifest is None: - raise HacsException( - f"The version {ref} for this {self.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 HacsException( - 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 HacsException(f"This version requires HACS {target_manifest.hacs} or newer.") - - async def async_download_repository(self, *, ref: str | None = None, **_) -> None: - """Download the content of a repository.""" - await self._ensure_download_capabilities(ref) - self.logger.info("Starting download, %s", ref) - if self.display_version_or_commit == "version": - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 10}, - ) - if not ref: - await self.update_repository(force=True) - else: - self.ref = ref - self.data.selected_tag = ref - self.force_branch = ref is not None - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": 20}, - ) - - try: - await self.async_install(version=ref) - except HacsException as exception: - raise HacsException( - f"Downloading {self.data.full_name} with version {ref or self.data.last_version or self.data.last_commit} failed with ({exception})" - ) from exception - finally: - self.data.selected_tag = None - self.force_branch = False - self.hacs.async_dispatch( - HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, - {"repository": self.data.full_name, "progress": False}, - ) - - async def async_get_releases(self, *, first: int = 30) -> list[GitHubReleaseModel]: - """Get the last x releases of a repository.""" - response = await self.hacs.async_github_api_method( - method=self.hacs.githubapi.repos.releases.list, - repository=self.data.full_name, - kwargs={"per_page": 30}, - ) - return response.data +"""Repository.""" + +from __future__ import annotations + +from asyncio import sleep +from datetime import UTC, datetime +import os +import pathlib +import shutil +import tempfile +from typing import TYPE_CHECKING, Any +import zipfile + +from aiogithubapi import ( + AIOGitHubAPIException, + AIOGitHubAPINotModifiedException, + GitHubReleaseModel, +) +from aiogithubapi.objects.repository import AIOGitHubAPIRepository +import attr +from homeassistant.helpers import device_registry as dr, issue_registry as ir + +from ..const import DOMAIN +from ..enums import HacsDispatchEvent, RepositoryFile +from ..exceptions import ( + HacsException, + HacsNotModifiedException, + HacsRepositoryArchivedException, + HacsRepositoryExistException, +) +from ..types import DownloadableContent +from ..utils.backup import Backup +from ..utils.decode import decode_content +from ..utils.decorator import concurrent, return_none_on_exception +from ..utils.file_system import async_exists, async_remove, async_remove_directory +from ..utils.filters import filter_content_return_one_of_type +from ..utils.github_graphql_query import GET_REPOSITORY_RELEASES +from ..utils.json import json_loads +from ..utils.logger import LOGGER +from ..utils.path import is_safe +from ..utils.queue_manager import QueueManager +from ..utils.store import async_remove_store +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, + version_left_higher_then_right, +) +from ..utils.workarounds import DOMAIN_OVERRIDES + +if TYPE_CHECKING: + from ..base import HacsBase + + +TOPIC_FILTER = ( + "add-on", + "addon", + "app", + "appdaemon-apps", + "appdaemon", + "custom-card", + "custom-cards", + "custom-component", + "custom-components", + "customcomponents", + "hacktoberfest", + "hacs-default", + "hacs-integration", + "hacs-repository", + "hacs", + "hass", + "hassio", + "home-assistant-custom", + "home-assistant-frontend", + "home-assistant-hacs", + "home-assistant-sensor", + "home-assistant", + "home-automation", + "homeassistant-components", + "homeassistant-integration", + "homeassistant-sensor", + "homeassistant", + "homeautomation", + "integration", + "lovelace-ui", + "lovelace", + "media-player", + "mediaplayer", + "plugin", + "python_script", + "python-script", + "python", + "sensor", + "smart-home", + "smarthome", + "template", + "templates", + "theme", + "themes", +) + + +REPOSITORY_KEYS_TO_EXPORT = ( + # Keys can not be removed from this list until v3 + # If keys are added, the action need to be re-run with force + ("description", ""), + ("downloads", 0), + ("domain", None), + ("etag_releases", None), + ("etag_repository", None), + ("full_name", ""), + ("last_commit", None), + ("last_updated", 0), + ("last_version", None), + ("manifest_name", None), + ("open_issues", 0), + ("prerelease", None), + ("stargazers_count", 0), + ("topics", []), +) + +HACS_MANIFEST_KEYS_TO_EXPORT = ( + # Keys can not be removed from this list until v3 + # If keys are added, the action need to be re-run with force + ("country", []), + ("name", None), +) + + +class FileInformation: + """FileInformation.""" + + def __init__(self, url, path, name): + self.download_url = url + self.path = path + self.name = name + + +@attr.s(auto_attribs=True) +class RepositoryData: + """RepositoryData class.""" + + archived: bool = False + authors: list[str] = [] + category: str = "" + config_flow: bool = False + default_branch: str = None + description: str = "" + domain: str = None + downloads: int = 0 + etag_repository: str = None + etag_releases: str = None + file_name: str = "" + first_install: bool = False + full_name: str = "" + hide: bool = False + has_issues: bool = True + id: int = 0 + installed_commit: str = None + installed_version: str = None + installed: bool = False + last_commit: str = None + last_fetched: datetime = None + last_updated: str = 0 + last_version: str = None + manifest_name: str = None + new: bool = True + open_issues: int = 0 + prerelease: str = None + published_tags: list[str] = [] + releases: bool = False + selected_tag: str = None + show_beta: bool = False + stargazers_count: int = 0 + topics: list[str] = [] + + @property + def name(self): + """Return the name.""" + if self.category == "integration": + return self.domain + return self.full_name.split("/")[-1] + + def to_json(self): + """Export to json.""" + return attr.asdict(self, filter=lambda attr, value: attr.name != "last_fetched") + + @staticmethod + def create_from_dict(source: dict, action: bool = False) -> RepositoryData: + """Set attributes from dicts.""" + data = RepositoryData() + data.update_data(source, action) + return data + + def update_data(self, data: dict, action: bool = False) -> None: + """Update data of the repository.""" + for key, value in data.items(): + if key not in self.__dict__: + continue + + if key == "last_fetched" and isinstance(value, float): + setattr(self, key, datetime.fromtimestamp(value, UTC)) + elif key == "id": + setattr(self, key, str(value)) + elif key == "country": + if isinstance(value, str): + setattr(self, key, [value]) + else: + setattr(self, key, value) + elif key == "topics" and not action: + setattr(self, key, [topic for topic in value if topic not in TOPIC_FILTER]) + + else: + setattr(self, key, value) + + +@attr.s(auto_attribs=True) +class HacsManifest: + """HacsManifest class.""" + + content_in_root: bool = False + country: list[str] = [] + filename: str = None + hacs: str = None # Minimum HACS version + hide_default_branch: bool = False + homeassistant: str = None # Minimum Home Assistant version + manifest: dict = {} + name: str = None + persistent_directory: str = None + render_readme: bool = False + content_languages: list[str] = [] + zip_release: bool = False + + def to_dict(self): + """Export to json.""" + return attr.asdict(self) + + @staticmethod + def from_dict(manifest: dict): + """Set attributes from dicts.""" + if manifest is None: + raise HacsException("Missing manifest data") + + manifest_data = HacsManifest() + manifest_data.manifest = { + k: v + for k, v in manifest.items() + if k in manifest_data.__dict__ and v != manifest_data.__getattribute__(k) + } + + for key, value in manifest_data.manifest.items(): + if key == "country" and isinstance(value, str): + setattr(manifest_data, key, [value]) + elif key == "content_languages": + if isinstance(value, str): + setattr(manifest_data, key, [value.lower()]) + elif isinstance(value, list): + setattr(manifest_data, key, [lang.lower() if isinstance(lang, str) else lang for lang in value]) + else: + setattr(manifest_data, key, value) + elif key in manifest_data.__dict__: + setattr(manifest_data, key, value) + return manifest_data + + def update_data(self, data: dict) -> None: + """Update the manifest data.""" + for key, value in data.items(): + if key not in self.__dict__: + continue + + if key == "country": + if isinstance(value, str): + setattr(self, key, [value]) + else: + setattr(self, key, value) + elif key == "content_languages": + if isinstance(value, str): + setattr(self, key, [value.lower()]) + elif isinstance(value, list): + setattr(self, key, [lang.lower() if isinstance(lang, str) else lang for lang in value]) + else: + setattr(self, key, value) + else: + setattr(self, key, value) + + +class RepositoryReleases: + """RepositoyReleases.""" + + last_release = None + last_release_object = None + published_tags = [] + objects: list[GitHubReleaseModel] = [] + releases = False + downloads = None + + +class RepositoryPath: + """RepositoryPath.""" + + local: str | None = None + remote: str | None = None + + +class RepositoryContent: + """RepositoryContent.""" + + path: RepositoryPath | None = None + files = [] + objects = [] + single = False + + +class HacsRepository: + """HacsRepository.""" + + def __init__(self, hacs: HacsBase) -> None: + """Set up HacsRepository.""" + self.hacs = hacs + self.additional_info = "" + self.data = RepositoryData() + self.content = RepositoryContent() + self.content.path = RepositoryPath() + self.repository_object: AIOGitHubAPIRepository | None = None + self.updated_info = False + self.state = None + self.force_branch = False + self.integration_manifest = {} + self.repository_manifest = HacsManifest.from_dict({}) + self.validate = Validate() + self.releases = RepositoryReleases() + self.pending_restart = False + self.tree = [] + self.treefiles = [] + self.ref = None + self.logger = LOGGER + + def __str__(self) -> str: + """Return a string representation of the repository.""" + return self.string + + @property + def string(self) -> str: + """Return a string representation of the repository.""" + return f"<{self.data.category.title()} {self.data.full_name}>" + + @property + def display_name(self) -> str: + """Return display name.""" + if self.repository_manifest.name is not None: + return self.repository_manifest.name + + if self.data.category == "integration": + if self.data.manifest_name is not None: + return self.data.manifest_name + if "name" in self.integration_manifest: + return self.integration_manifest["name"] + + return self.data.full_name.split("/")[-1].replace("-", " ").replace("_", " ").title() + + @property + def ignored_by_country_configuration(self) -> bool: + """Return True if hidden by country.""" + if self.data.installed: + return False + configuration = self.hacs.configuration.country.lower() + if configuration == "all": + return False + + manifest = [entry.lower() for entry in self.repository_manifest.country or []] + if not manifest: + return False + return configuration not in manifest + + @property + def display_status(self) -> str: + """Return display_status.""" + if self.data.new: + status = "new" + elif self.pending_restart: + status = "pending-restart" + elif self.pending_update: + status = "pending-upgrade" + elif self.data.installed: + status = "installed" + else: + status = "default" + return status + + @property + def display_installed_version(self) -> str: + """Return display_authors""" + if self.data.installed_version is not None: + installed = self.data.installed_version + else: + if self.data.installed_commit is not None: + installed = self.data.installed_commit + else: + installed = "" + return str(installed) + + @property + def display_available_version(self) -> str: + """Return display_authors""" + if self.data.show_beta and self.data.prerelease is not None: + available = self.data.prerelease + elif self.data.last_version is not None: + available = self.data.last_version + else: + if self.data.last_commit is not None: + available = self.data.last_commit + else: + available = "" + return str(available) + + @property + def display_version_or_commit(self) -> str: + """Does the repositoriy use releases or commits?""" + if self.data.releases: + version_or_commit = "version" + else: + version_or_commit = "commit" + return version_or_commit + + @property + def pending_update(self) -> bool: + """Return True if pending update.""" + if self.data.installed: + if self.data.selected_tag is not None: + if self.data.selected_tag == self.data.default_branch: + if self.data.installed_commit != self.data.last_commit: + return True + return False + if self.display_version_or_commit == "version": + if ( + result := version_left_higher_then_right( + self.display_available_version, + self.display_installed_version, + ) + ) is not None: + return result + if self.display_installed_version != self.display_available_version: + return True + + return False + + @property + def can_download(self) -> bool: + """Return True if we can download.""" + if self.repository_manifest.homeassistant is not None: + if self.data.releases: + if not version_left_higher_or_equal_then_right( + self.hacs.core.ha_version.string, + self.repository_manifest.homeassistant, + ): + return False + return True + + @property + def localpath(self) -> str | None: + """Return localpath.""" + return None + + @property + def should_try_releases(self) -> bool: + """Return a boolean indicating whether to download releases or not.""" + if self.repository_manifest.zip_release: + if self.repository_manifest.filename.endswith(".zip"): + if self.ref != self.data.default_branch: + return True + if self.ref == self.data.default_branch: + return False + if self.data.category not in ["plugin", "theme"]: + return False + if not self.data.releases: + return False + return True + + async def validate_repository(self) -> None: + """Validate.""" + + @concurrent(concurrenttasks=10, backoff_time=5) + async def update_repository(self, ignore_issues=False, force=False) -> None: + """Update the repository""" + + async def common_validate(self, ignore_issues: bool = False) -> None: + """Common validation steps of the repository.""" + self.validate.errors.clear() + + # Make sure the repository exist. + self.logger.debug("%s Checking repository.", self.string) + await self.common_update_data(ignore_issues=ignore_issues) + + # Get the content of hacs.json + if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: + if manifest := await self.async_get_hacs_json(): + self.repository_manifest = HacsManifest.from_dict(manifest) + self.data.update_data( + self.repository_manifest.to_dict(), + action=self.hacs.system.action, + ) + + async def common_registration(self) -> None: + """Common registration steps of the repository.""" + # Attach repository + if self.repository_object is None: + try: + self.repository_object, etag = await self.async_get_legacy_repository_object( + etag=None if self.data.installed else self.data.etag_repository, + ) + self.data.update_data( + self.repository_object.attributes, + action=self.hacs.system.action, + ) + self.data.etag_repository = etag + except HacsNotModifiedException: + self.logger.debug("%s Did not update, content was not modified", self.string) + return + + if self.repository_object: + self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) + self.data.last_fetched = datetime.now(UTC) + + @concurrent(concurrenttasks=10, backoff_time=5) + async def common_update(self, ignore_issues=False, force=False, skip_releases=False) -> bool: + """Common information update steps of the repository.""" + self.logger.debug("%s Getting repository information", self.string) + + # Attach repository + current_etag = self.data.etag_repository + try: + await self.common_update_data( + ignore_issues=ignore_issues, + force=force, + skip_releases=skip_releases, + ) + except HacsRepositoryExistException: + self.data.full_name = self.hacs.common.renamed_repositories[self.data.full_name] + await self.common_update_data(ignore_issues=ignore_issues, force=force) + + except HacsException: + if not ignore_issues and not force: + return False + + if not self.data.installed and (current_etag == self.data.etag_repository) and not force: + self.logger.debug("%s Did not update, content was not modified", self.string) + return False + + # Update last updated + if self.repository_object: + self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0) + + # Update last available commit + await self.repository_object.set_last_commit() + self.data.last_commit = self.repository_object.last_commit + + # Get the content of hacs.json + if RepositoryFile.HACS_JSON in [x.filename for x in self.tree]: + if manifest := await self.async_get_hacs_json(): + self.repository_manifest = HacsManifest.from_dict(manifest) + self.data.update_data( + self.repository_manifest.to_dict(), + action=self.hacs.system.action, + ) + + # Update "info.md" + self.additional_info = await self.async_get_info_file_contents() + + # Set last fetch attribute + self.data.last_fetched = datetime.now(UTC) + + return True + + async def download_zip_files(self, validate: Validate) -> None: + """Download ZIP archive from repository release.""" + + try: + 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, + ) + # lgtm [py/catch-base-exception] pylint: disable=broad-except + except BaseException: + validate.errors.append( + f"Download of {self.repository_manifest.filename} was not completed" + ) + + 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["url"]) + + if filecontent is None: + validate.errors.append(f"Failed to download {content['url']}") + return + + temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) + temp_file = f"{temp_dir}/{self.repository_manifest.filename}" + + result = await self.hacs.async_save_file(temp_file, filecontent) + + def _extract_zip_file(): + with zipfile.ZipFile(temp_file, "r") as zip_file: + zip_file.extractall(self.content.path.local) + + await self.hacs.hass.async_add_executor_job(_extract_zip_file) + + def cleanup_temp_dir(): + """Cleanup temp_dir.""" + if os.path.exists(temp_dir): + self.logger.debug("%s Cleaning up %s", self.string, temp_dir) + shutil.rmtree(temp_dir) + + if result: + 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") + # lgtm [py/catch-base-exception] pylint: disable=broad-except + except BaseException: + validate.errors.append("Download was not completed") + + async def download_content(self, version: string | None = None) -> None: + """Download the content of a directory.""" + contents: list[FileInformation] | None = None + if ( + not self.repository_manifest.zip_release + and not self.data.file_name + and self.content.path.remote is not None + ): + self.logger.info("%s Downloading repository archive", self.string) + try: + await self.download_repository_zip() + return + except HacsException as exception: + self.logger.exception(exception) + + 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") + + download_queue = QueueManager(hass=self.hacs.hass) + + for content in contents: + if self.repository_manifest.content_in_root and self.repository_manifest.filename: + if content.name != self.repository_manifest.filename: + continue + download_queue.add(self.dowload_repository_content(content)) + + await download_queue.execute() + + async def download_repository_zip(self): + """Download the zip archive of the repository.""" + ref = f"{self.ref}".replace("tags/", "") + + if not ref: + raise HacsException("Missing required elements.") + + filecontent = await self.hacs.async_download_file( + 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") + + temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp) + temp_file = f"{temp_dir}/{self.repository_manifest.filename}" + result = await self.hacs.async_save_file(temp_file, filecontent) + if not result: + raise HacsException("Could not save ZIP file") + + def _extract_zip_file(): + with zipfile.ZipFile(temp_file, "r") as zip_file: + extractable = [] + for path in zip_file.filelist: + filename = "/".join(path.filename.split("/")[1:]) + if ( + filename.startswith(self.content.path.remote) + 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) + + await self.hacs.hass.async_add_executor_job(_extract_zip_file) + + def cleanup_temp_dir(): + """Cleanup temp_dir.""" + if os.path.exists(temp_dir): + self.logger.debug("%s Cleaning up %s", self.string, temp_dir) + shutil.rmtree(temp_dir) + + await self.hacs.hass.async_add_executor_job(cleanup_temp_dir) + self.logger.info("%s Content was extracted to %s", self.string, self.content.path.local) + + async def async_get_hacs_json(self, ref: str = None) -> dict[str, Any] | None: + """Get the content of the hacs.json file.""" + 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=RepositoryFile.HACS_JSON, + **{"params": {"ref": ref or self.version_to_download()}}, + ) + if response: + return json_loads(decode_content(response.data.content)) + # lgtm [py/catch-base-exception] pylint: disable=broad-except + except BaseException: + pass + + async def async_get_info_file_contents(self, *, version: str | None = None, **kwargs) -> str: + """Get the content of the info.md file.""" + + def _info_file_variants() -> tuple[str, ...]: + name: str = "readme" + return ( + f"{name.upper()}.md", + f"{name}.md", + f"{name}.MD", + f"{name.upper()}.MD", + name.upper(), + name, + ) + + info_files = [filename for filename in _info_file_variants() if filename in self.treefiles] + + if not info_files: + return "" + + return await self.get_documentation(filename=info_files[0], version=version) or "" + + async def async_get_info_file_contents_with_language( + self, *, language: str | None = None, version: str | None = None, **kwargs + ) -> str: + """Get the content of the info.md file with language support. + + Args: + language: Optional language code (e.g., "de", "en", "fr") + version: Optional version/ref to get the file from + + Returns: + README content as string + """ + if language: + language = language.split("-")[0].lower() if "-" in language else language.lower() + + if language: + if not language.isalpha() or len(language) != 2: + self.logger.warning( + "%s Invalid language code: %s, using README.md", + self.string, + language, + ) + language = None + else: + if ( + self.repository_manifest.content_languages + and language not in self.repository_manifest.content_languages + ): + self.logger.debug( + "%s Language '%s' not in content_languages %s, using README.md", + self.string, + language, + self.repository_manifest.content_languages, + ) + language = None + + if not language or language == "en": + return await self.async_get_info_file_contents(version=version) + + readme_path = f"README.{language}.md" + + possible_paths = [ + f"README.{language}.md", + f"README.{language.upper()}.md", + f"readme.{language}.md", + f"readme.{language.upper()}.md", + f"README.{language}.MD", + f"README.{language.upper()}.MD", + ] + + found_path = None + for path in possible_paths: + if path in self.treefiles: + found_path = path + break + + if found_path: + try: + content = await self.get_documentation(filename=found_path, version=version) + if content: + return content + except Exception as e: + self.logger.warning( + "%s Error loading %s: %s, falling back to README.md", + self.string, + found_path, + e, + ) + + self.logger.debug( + "%s Language-specific README %s not found, using README.md", + self.string, + readme_path, + ) + return await self.async_get_info_file_contents(version=version) + + def remove(self) -> None: + """Run remove tasks.""" + if self.hacs.repositories.is_registered(repository_id=str(self.data.id)): + self.logger.info("%s Starting removal", self.string) + self.hacs.repositories.unregister(self) + + async def uninstall(self) -> None: + """Run uninstall tasks.""" + self.logger.info("%s Removing", self.string) + if not await self.remove_local_directory(): + raise HacsException("Could not uninstall") + self.data.installed = False + await self._async_post_uninstall() + await async_remove_store(self.hacs.hass, f"hacs/{self.data.id}.hacs") + + self.data.installed_version = None + self.data.installed_commit = None + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY, + { + "id": 1337, + "action": "uninstall", + "repository": self.data.full_name, + "repository_id": self.data.id, + }, + ) + + await self.async_remove_entity_device() + ir.async_delete_issue(self.hacs.hass, DOMAIN, f"removed_{self.data.id}") + + async def remove_local_directory(self) -> None: + """Check the local directory.""" + + try: + if self.data.category == "python_script": + 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": + path = ( + f"{self.hacs.core.config_path}/" + f"{self.hacs.configuration.theme_path}/" + f"{self.data.name}.yaml" + ) + await async_remove(self.hacs.hass, path, missing_ok=True) + local_path = self.content.path.local + elif self.data.category == "integration": + if not self.data.domain: + if domain := DOMAIN_OVERRIDES.get(self.data.full_name): + self.data.domain = domain + self.content.path.local = self.localpath + else: + self.logger.error("%s Missing domain", self.string) + return False + local_path = self.content.path.local + else: + local_path = self.content.path.local + + if await async_exists(self.hacs.hass, local_path): + if not is_safe(self.hacs, local_path): + self.logger.error("%s Path %s is blocked from removal", self.string, local_path) + return False + self.logger.debug("%s Removing %s", self.string, local_path) + + if self.data.category in ["python_script", "template"]: + await async_remove(self.hacs.hass, local_path) + else: + await async_remove_directory(self.hacs.hass, local_path) + + while await async_exists(self.hacs.hass, local_path): + await sleep(1) + else: + self.logger.debug( + "%s Presumed local content path %s does not exist", self.string, local_path + ) + + except ( + # lgtm [py/catch-base-exception] pylint: disable=broad-except + BaseException + ) as exception: + self.logger.debug("%s Removing %s failed with %s", self.string, local_path, exception) + return False + return True + + async def async_pre_registration(self) -> None: + """Run pre registration steps.""" + + @concurrent(concurrenttasks=10) + async def async_registration(self, ref=None) -> None: + """Run registration steps.""" + await self.async_pre_registration() + + if ref is not None: + self.data.selected_tag = ref + self.ref = ref + self.force_branch = True + + if not await self.validate_repository(): + return False + + # Run common registration steps. + await self.common_registration() + + # Set correct local path + self.content.path.local = self.localpath + + # Run local post registration steps. + await self.async_post_registration() + + async def async_post_registration(self) -> None: + """Run post registration steps.""" + if not self.hacs.system.action: + return + await self.hacs.validation.async_run_repository_checks(self) + + async def async_pre_install(self) -> None: + """Run pre install steps.""" + + async def _async_pre_install(self) -> None: + """Run pre install steps.""" + self.logger.info("%s Running pre installation steps", self.string) + await self.async_pre_install() + self.logger.info("%s Pre installation steps completed", self.string) + + 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(version=version) + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 90}, + ) + self.logger.info("%s Installation steps completed", self.string) + await self._async_post_install() + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": False}, + ) + + async def async_post_installation(self) -> None: + """Run post install steps.""" + + async def async_post_uninstall(self): + """Run post uninstall steps.""" + + async def _async_post_uninstall(self): + """Run post uninstall steps.""" + await self.async_post_uninstall() + + async def _async_post_install(self) -> None: + """Run post install steps.""" + self.logger.info("%s Running post installation steps", self.string) + await self.async_post_installation() + self.data.new = False + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY, + { + "id": 1337, + "action": "install", + "repository": self.data.full_name, + "repository_id": self.data.id, + }, + ) + self.logger.info("%s Post installation steps completed", self.string) + + async def async_install_repository(self, *, version: str | None = None, **_) -> None: + """Common installation steps of the repository.""" + persistent_directory = None + force_update = version is None or ( + self.data.last_version is not None and version != self.data.last_version + ) + await self.update_repository(force=force_update) + if self.content.path.local is None: + raise HacsException("repository.content.path.local is None") + self.validate.errors.clear() + + 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_to_install}" + + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 40}, + ) + + if self.repository_manifest.persistent_directory: + if await async_exists( + self.hacs.hass, + f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", + ): + persistent_directory = Backup( + hacs=self.hacs, + local_path=f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", + backup_path=tempfile.gettempdir() + "/hacs_persistent_directory/", + ) + await self.hacs.hass.async_add_executor_job(persistent_directory.create) + + if self.data.installed and not self.content.single: + backup = Backup(hacs=self.hacs, local_path=self.content.path.local) + await self.hacs.hass.async_add_executor_job(backup.create) + + 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 self.repository_manifest.filename: + await self.download_zip_files(self.validate) + else: + await self.download_content(version_to_install) + + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 70}, + ) + + if self.validate.errors: + for error in self.validate.errors: + self.logger.error("%s %s", self.string, error) + if self.data.installed and not self.content.single: + await self.hacs.hass.async_add_executor_job(backup.restore) + await self.hacs.hass.async_add_executor_job(backup.cleanup) + raise HacsException("Could not download, see log for details") + + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 80}, + ) + + if self.data.installed and not self.content.single: + await self.hacs.hass.async_add_executor_job(backup.cleanup) + + if persistent_directory is not None: + await self.hacs.hass.async_add_executor_job(persistent_directory.restore) + await self.hacs.hass.async_add_executor_job(persistent_directory.cleanup) + + if self.validate.success: + self.data.installed = True + self.data.installed_commit = self.data.last_commit + + if version_to_install == self.data.default_branch: + self.data.installed_version = None + else: + self.data.installed_version = version_to_install + + async def async_get_legacy_repository_object( + self, + etag: str | None = None, + ) -> tuple[AIOGitHubAPIRepository, Any | None]: + """Return a repository object.""" + try: + repository = await self.hacs.github.get_repo(self.data.full_name, etag) + return repository, self.hacs.github.client.last_response.etag + except AIOGitHubAPINotModifiedException as exception: + raise HacsNotModifiedException(exception) from exception + except (ValueError, AIOGitHubAPIException, Exception) as exception: + raise HacsException(exception) from exception + + def update_filenames(self) -> None: + """Get the filename to target.""" + + async def get_tree(self, ref: str): + """Return the repository tree.""" + if self.repository_object is None: + raise HacsException("No repository_object") + try: + tree = await self.repository_object.get_tree(ref) + return tree + except (ValueError, AIOGitHubAPIException) as exception: + raise HacsException(exception) from exception + + async def get_releases(self, prerelease=False, returnlimit=5) -> list[GitHubReleaseModel]: + """Return the repository releases.""" + response = await self.hacs.async_github_api_method( + method=self.hacs.githubapi.repos.releases.list, + repository=self.data.full_name, + ) + releases = [] + for release in response.data or []: + if len(releases) == returnlimit: + break + if release.draft or (release.prerelease and not prerelease): + continue + releases.append(release) + return releases + + async def common_update_data( + self, + ignore_issues: bool = False, + force: bool = False, + retry=False, + skip_releases=False, + ) -> None: + """Common update data.""" + releases = [] + try: + repository_object, etag = await self.async_get_legacy_repository_object( + etag=None if force or self.data.installed else self.data.etag_repository, + ) + self.repository_object = repository_object + if self.data.full_name.lower() != repository_object.full_name.lower(): + self.hacs.common.renamed_repositories[self.data.full_name] = ( + repository_object.full_name + ) + if not self.hacs.system.generator: + raise HacsRepositoryExistException + self.logger.error( + "%s Repository has been renamed - %s", self.string, repository_object.full_name + ) + self.data.update_data( + repository_object.attributes, + action=self.hacs.system.action, + ) + self.data.etag_repository = etag + except HacsNotModifiedException: + return + except HacsRepositoryExistException: + raise HacsRepositoryExistException from None + except (AIOGitHubAPIException, HacsException) as exception: + if not self.hacs.status.startup or self.hacs.system.generator: + self.logger.error("%s %s", self.string, exception) + if not ignore_issues: + self.validate.errors.append("Repository does not exist.") + raise HacsException(exception) from exception + + # Make sure the repository is not archived. + if self.data.archived and not ignore_issues: + self.validate.errors.append("Repository is archived.") + if self.data.full_name not in self.hacs.common.archived_repositories: + self.hacs.common.archived_repositories.add(self.data.full_name) + raise HacsRepositoryArchivedException(f"{self} Repository is archived.") + + # Make sure the repository is not in the blacklist. + if self.hacs.repositories.is_removed(self.data.full_name): + removed = self.hacs.repositories.removed_repository(self.data.full_name) + if removed.removal_type != "remove" and not ignore_issues: + self.validate.errors.append("Repository has been requested to be removed.") + raise HacsException(f"{self} Repository has been requested to be removed.") + + # Get releases. + if not skip_releases: + try: + releases = await self.get_releases(prerelease=True, returnlimit=30) + if releases: + self.data.prerelease = None + for release in releases: + if release.draft: + continue + elif release.prerelease: + if self.data.prerelease is None: + self.data.prerelease = release.tag_name + else: + self.data.last_version = release.tag_name + break + + self.data.releases = True + + filtered_releases = [ + release + for release in releases + if not release.draft and (self.data.show_beta or not release.prerelease) + ] + self.releases.objects = filtered_releases + self.data.published_tags = [x.tag_name for x in filtered_releases] + + except HacsException: + self.data.releases = False + + if not self.force_branch: + self.ref = self.version_to_download() + if self.data.releases: + for release in self.releases.objects or []: + if release.tag_name == self.ref: + if assets := release.assets: + downloads = next(iter(assets)).download_count + self.data.downloads = downloads + elif self.hacs.system.generator and self.repository_object: + await self.repository_object.set_last_commit() + self.data.last_commit = self.repository_object.last_commit + + self.hacs.log.debug( + "%s Running checks against %s", self.string, self.ref.replace("tags/", "") + ) + + try: + self.tree = await self.get_tree(self.ref) + if not self.tree: + raise HacsException("No files in tree") + self.treefiles = [] + for treefile in self.tree: + self.treefiles.append(treefile.full_path) + except (AIOGitHubAPIException, HacsException) as exception: + if ( + not retry + and self.ref is not None + and str(exception).startswith("GitHub returned 404") + ): + # Handle tags/branches being deleted. + self.data.selected_tag = None + self.ref = self.version_to_download() + self.logger.warning( + "%s Selected version/branch %s has been removed, falling back to default", + self.string, + self.ref, + ) + return await self.common_update_data(ignore_issues, force, True) + if not self.hacs.status.startup and not ignore_issues: + self.logger.error("%s %s", self.string, exception) + if not ignore_issues: + raise HacsException(exception) from None + + def gather_files_to_download(self) -> list[FileInformation]: + """Return a list of file objects to be downloaded.""" + files = [] + tree = self.tree + ref = f"{self.ref}".replace("tags/", "") + releaseobjects = self.releases.objects + category = self.data.category + remotelocation = self.content.path.remote + + if self.should_try_releases: + for release in releaseobjects or []: + if ref == release.tag_name: + for asset in release.assets or []: + files.append( + FileInformation(asset.browser_download_url, asset.name, asset.name) + ) + if files: + return files + + if self.content.single: + for treefile in tree: + if treefile.filename == self.data.file_name: + files.append( + FileInformation( + treefile.download_url, treefile.full_path, treefile.filename + ) + ) + return files + + if category == "plugin": + for treefile in tree: + if treefile.path in ["", "dist"]: + if remotelocation == "dist" and not treefile.filename.startswith("dist"): + continue + if not remotelocation: + if not treefile.filename.endswith(".js"): + continue + if treefile.path != "": + continue + if not treefile.is_directory: + files.append( + FileInformation( + treefile.download_url, treefile.full_path, treefile.filename + ) + ) + if files: + return files + + if self.repository_manifest.content_in_root: + if not self.repository_manifest.filename: + if category == "theme": + tree = filter_content_return_one_of_type(self.tree, "", "yaml", "full_path") + + for path in tree: + if path.is_directory: + continue + if path.full_path.startswith(self.content.path.remote): + 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.""" + try: + self.logger.debug("%s Downloading %s", self.string, content.name) + + filecontent = await self.hacs.async_download_file(content.download_url) + + if filecontent is None: + self.validate.errors.append(f"[{content.name}] was not downloaded.") + return + + # Save the content of the file. + if self.content.single or content.path is None: + local_directory = self.content.path.local + + else: + _content_path = content.path + if not self.repository_manifest.content_in_root: + _content_path = _content_path.replace(f"{self.content.path.remote}", "") + + local_directory = f"{self.content.path.local}/{_content_path}" + local_directory = local_directory.split("/") + del local_directory[-1] + local_directory = "/".join(local_directory) + + # Check local directory + pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True) + + local_file_path = (f"{local_directory}/{content.name}").replace("//", "/") + + result = await self.hacs.async_save_file(local_file_path, filecontent) + if result: + self.logger.info("%s Download of %s completed", self.string, content.name) + return + self.validate.errors.append(f"[{content.name}] was not downloaded.") + + except ( + # lgtm [py/catch-base-exception] pylint: disable=broad-except + BaseException + ) as exception: + self.validate.errors.append(f"Download was not completed [{exception}]") + + async def async_remove_entity_device(self) -> None: + """Remove the entity device.""" + device_registry: dr.DeviceRegistry = dr.async_get(hass=self.hacs.hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, str(self.data.id))}) + + if device is None: + return + + device_registry.async_remove_device(device_id=device.id) + + def version_to_download(self) -> str: + """Determine which version to download.""" + if self.force_branch and self.ref is not None: + return self.ref + + if self.data.last_version is not None: + if self.data.selected_tag is not None: + if self.data.selected_tag == self.data.last_version: + self.data.selected_tag = None + return self.data.last_version + return self.data.selected_tag + return self.data.last_version + + if self.data.selected_tag is not None: + if self.data.selected_tag == self.data.default_branch: + return self.data.default_branch + if self.data.selected_tag in self.data.published_tags: + return self.data.selected_tag + + return self.data.default_branch or "main" + + async def get_documentation( + self, + *, + filename: str | None = None, + version: str | None = None, + **kwargs, + ) -> str | None: + """Get the documentation of the repository.""" + if filename is None: + return None + + if version is not None: + target_version = version + elif self.data.installed: + target_version = self.data.installed_version or self.data.installed_commit + else: + target_version = 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, + target_version, + filename, + ) + if target_version is None: + return None + + result = await self.hacs.async_download_file( + f"https://raw.githubusercontent.com/{self.data.full_name}/{target_version}/{filename}", + nolog=True, + ) + + return ( + result.decode(encoding="utf-8") + .replace(" HacsManifest | None: + """Get the hacs.json file of the repository.""" + if (result := await self.get_hacs_json_raw(version=version)) is None: + return None + return HacsManifest.from_dict(result) + + @return_none_on_exception + async def get_hacs_json_raw( + self, + *, + version: str, + **kwargs, + ) -> dict[str, Any] | None: + """Get the hacs.json file of the repository.""" + self.logger.debug("%s Getting hacs.json for version=%s", self.string, version) + result = await self.hacs.async_download_file( + f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/hacs.json", + nolog=True, + handle_rate_limit=True, + ) + return json_loads(result) if result else None + + async def _ensure_download_capabilities(self, ref: str | None, **kwargs: Any) -> None: + """Ensure that the download can be handled.""" + target_manifest: HacsManifest | None = None + if ref is None: + if not self.can_download: + raise HacsException( + f"This {self.data.category.value} is not available for download." + ) + return + + if not ref: + target_manifest = self.repository_manifest + else: + target_manifest = await self.get_hacs_json(version=ref) + + if target_manifest is None: + raise HacsException( + f"The version {ref} for this {self.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 HacsException( + 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 HacsException(f"This version requires HACS {target_manifest.hacs} or newer.") + + async def async_download_repository(self, *, ref: str | None = None, **_) -> None: + """Download the content of a repository.""" + await self._ensure_download_capabilities(ref) + self.logger.info("Starting download, %s", ref) + if self.display_version_or_commit == "version": + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 10}, + ) + if not ref: + await self.update_repository(force=True) + else: + self.ref = ref + self.data.selected_tag = ref + self.force_branch = ref is not None + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": 20}, + ) + + try: + await self.async_install(version=ref) + except HacsException as exception: + raise HacsException( + f"Downloading {self.data.full_name} with version {ref or self.data.last_version or self.data.last_commit} failed with ({exception})" + ) from exception + finally: + self.data.selected_tag = None + self.force_branch = False + self.hacs.async_dispatch( + HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS, + {"repository": self.data.full_name, "progress": False}, + ) + + async def async_get_releases(self, *, first: int = 30) -> list[GitHubReleaseModel]: + """Get the last x releases of a repository.""" + response = await self.hacs.async_github_api_method( + method=self.hacs.githubapi.repos.releases.list, + repository=self.data.full_name, + kwargs={"per_page": 30}, + ) + return response.data diff --git a/custom_components/hacs/utils/validate.py b/custom_components/hacs/utils/validate.py index fa25be9af8a..d626038fcab 100644 --- a/custom_components/hacs/utils/validate.py +++ b/custom_components/hacs/utils/validate.py @@ -43,6 +43,39 @@ def _country_validator(values) -> list[str]: return countries +def _content_languages_validator(values) -> list[str]: + """Custom content_languages validator.""" + if values is None: + return [] + + languages = [] + if isinstance(values, str): + if not values.isalpha() or len(values) != 2: + raise vol.Invalid( + f"Language code '{values}' must be a 2-letter alphabetic code (e.g., 'de', 'fr', 'es').", + path=["content_languages"], + ) + languages.append(values.lower()) + elif isinstance(values, list): + for value in values: + if not isinstance(value, str): + raise vol.Invalid( + f"Language code '{value}' is not a string.", path=["content_languages"] + ) + if not value.isalpha() or len(value) != 2: + raise vol.Invalid( + f"Language code '{value}' must be a 2-letter alphabetic code (e.g., 'de', 'fr', 'es').", + path=["content_languages"], + ) + languages.append(value.lower()) + else: + raise vol.Invalid( + f"Value '{values}' is not a string or list.", path=["content_languages"] + ) + + return languages + + HACS_MANIFEST_JSON_SCHEMA = vol.Schema( { vol.Optional("content_in_root"): bool, @@ -53,6 +86,7 @@ def _country_validator(values) -> list[str]: vol.Optional("homeassistant"): str, vol.Optional("persistent_directory"): str, vol.Optional("render_readme"): bool, + vol.Optional("content_languages"): _content_languages_validator, vol.Optional("zip_release"): bool, vol.Required("name"): str, }, diff --git a/custom_components/hacs/validate/hacsjson.py b/custom_components/hacs/validate/hacsjson.py index bc989321f1b..8f3608ed684 100644 --- a/custom_components/hacs/validate/hacsjson.py +++ b/custom_components/hacs/validate/hacsjson.py @@ -1,44 +1,80 @@ -from __future__ import annotations - -from voluptuous.error import Invalid -from voluptuous.humanize import humanize_error - -from ..enums import HacsCategory, RepositoryFile -from ..repositories.base import HacsManifest, HacsRepository -from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA -from .base import ActionValidationBase, ValidationException - - -async def async_setup_validator(repository: HacsRepository) -> Validator: - """Set up this validator.""" - return Validator(repository=repository) - - -class Validator(ActionValidationBase): - """Validate the repository.""" - - more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest" - - async def async_validate(self) -> None: - """Validate the repository.""" - if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]: - raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file") - - rawhacsjson = await self.repository.get_hacs_json_raw(version=self.repository.ref) - if rawhacsjson is None: - raise ValidationException( - f"The repository has an invalid '{RepositoryFile.HACS_JSON}' file" - ) - - try: - hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(rawhacsjson)) - except Invalid as exception: - self.repository.logger.warning( - "HACS JSON validation failed for: %s", - rawhacsjson, - ) - raise ValidationException(humanize_error(rawhacsjson, 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") +from __future__ import annotations + +from voluptuous.error import Invalid +from voluptuous.humanize import humanize_error + +from ..enums import HacsCategory, RepositoryFile +from ..repositories.base import HacsManifest, HacsRepository +from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA +from .base import ActionValidationBase, ValidationException + + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + """Validate the repository.""" + + more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest" + + async def async_validate(self) -> None: + """Validate the repository.""" + if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]: + raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file") + + rawhacsjson = await self.repository.get_hacs_json_raw(version=self.repository.ref) + if rawhacsjson is None: + raise ValidationException( + f"The repository has an invalid '{RepositoryFile.HACS_JSON}' file" + ) + + try: + hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(rawhacsjson)) + except Invalid as exception: + self.repository.logger.warning( + "HACS JSON validation failed for: %s", + rawhacsjson, + ) + raise ValidationException(humanize_error(rawhacsjson, 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") + + if hacsjson.content_languages: + tree_files = [x.filename for x in self.repository.tree] + missing_readmes = [] + invalid_languages = [] + for lang in hacsjson.content_languages: + if not lang.isalpha() or len(lang) != 2: + invalid_languages.append(lang) + continue + + readme_path = f"README.{lang}.md" + readme_found = False + for possible_path in [ + readme_path, + f"README.{lang.upper()}.md", + f"readme.{lang}.md", + f"readme.{lang.upper()}.md", + f"README.{lang}.MD", + f"README.{lang.upper()}.MD", + ]: + if possible_path in tree_files: + readme_found = True + break + if not readme_found: + missing_readmes.append(lang) + + if invalid_languages: + raise ValidationException( + f"content_languages contains invalid language codes {invalid_languages}. " + f"Language codes must be 2-letter alphabetic codes (e.g., 'de', 'fr', 'es')." + ) + if missing_readmes: + raise ValidationException( + f"content_languages declares languages {missing_readmes}, " + f"but corresponding README files (README.{{lang}}.md) were not found in the repository." + ) \ No newline at end of file diff --git a/custom_components/hacs/websocket/repositories.py b/custom_components/hacs/websocket/repositories.py index 879f68af8da..4d481c97152 100644 --- a/custom_components/hacs/websocket/repositories.py +++ b/custom_components/hacs/websocket/repositories.py @@ -1,216 +1,222 @@ -"""Register info websocket commands.""" - -from __future__ import annotations - -import sys -from typing import TYPE_CHECKING, Any - -from homeassistant.components import websocket_api -import homeassistant.helpers.config_validation as cv -import voluptuous as vol - -from custom_components.hacs.utils import regex - -from ..const import DOMAIN -from ..enums import HacsDispatchEvent - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - - from ..base import HacsBase - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/list", - vol.Optional("categories"): [str], - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_list( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """List repositories.""" - hacs: HacsBase = hass.data.get(DOMAIN) - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "authors": repo.data.authors, - "available_version": repo.display_available_version, - "installed_version": repo.display_installed_version, - "config_flow": repo.data.config_flow, - "can_download": repo.can_download, - "category": repo.data.category, - "country": repo.repository_manifest.country, - "custom": not hacs.repositories.is_default(str(repo.data.id)), - "description": repo.data.description, - "domain": repo.data.domain, - "downloads": repo.data.downloads, - "file_name": repo.data.file_name, - "full_name": repo.data.full_name, - "hide": repo.data.hide, - "homeassistant": repo.repository_manifest.homeassistant, - "id": repo.data.id, - "installed": repo.data.installed, - "last_updated": repo.data.last_updated, - "local_path": repo.content.path.local, - "name": repo.display_name, - "new": repo.data.new, - "pending_upgrade": repo.pending_update, - "stars": repo.data.stargazers_count, - "state": repo.state, - "status": repo.display_status, - "topics": repo.data.topics, - } - for repo in hacs.repositories.list_all - if repo.data.category in msg.get("categories", hacs.common.categories) - and not repo.ignored_by_country_configuration - and repo.data.last_fetched - ], - ) - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/clear_new", - vol.Optional("categories"): cv.ensure_list, - vol.Optional("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_clear_new( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Clear new repositories for specific categories.""" - hacs: HacsBase = hass.data.get(DOMAIN) - - if repo := msg.get("repository"): - repository = hacs.repositories.get_by_id(repo) - repository.data.new = False - - else: - for repo in hacs.repositories.list_all: - if repo.data.new and repo.data.category in msg.get("categories", []): - hacs.log.debug( - "Clearing new flag from '%s'", - repo.data.full_name, - ) - repo.data.new = False - hacs.async_dispatch(HacsDispatchEvent.REPOSITORY, {}) - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"])) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/removed", - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_removed( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Get information about removed repositories.""" - hacs: HacsBase = hass.data.get(DOMAIN) - content = [] - for repo in hacs.repositories.list_removed: - if repo.repository not in hacs.common.ignored_repositories: - content.append(repo.to_json()) - connection.send_message(websocket_api.result_message(msg["id"], content)) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/add", - vol.Required("repository"): cv.string, - vol.Required("category"): vol.Lower, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_add( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Add custom repositoriy.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = regex.extract_repository_from_url(msg["repository"]) - category = msg["category"] - - if repository is None: - return - - if repository in hacs.common.skip: - hacs.common.skip.remove(repository) - - if renamed := hacs.common.renamed_repositories.get(repository): - repository = renamed - - if category not in hacs.common.categories: - hacs.log.error("%s is not a valid category for %s", category, repository) - - elif not hacs.repositories.get_by_full_name(repository): - try: - await hacs.async_register_repository( - repository_full_name=repository, - category=category, - ) - - except ( - BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except - ) as exception: - hacs.async_dispatch( - HacsDispatchEvent.ERROR, - { - "action": "add_repository", - "exception": str(sys.exc_info()[0].__name__), - "message": str(exception), - }, - ) - - else: - hacs.async_dispatch( - HacsDispatchEvent.ERROR, - { - "action": "add_repository", - "message": f"Repository '{repository}' exists in the store.", - }, - ) - - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repositories/remove", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repositories_remove( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Remove custom repositoriy.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.remove() - await hacs.data.async_write() - - connection.send_message(websocket_api.result_message(msg["id"], {})) +"""Register info websocket commands.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any + +from homeassistant.components import websocket_api +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from custom_components.hacs.utils import regex + +from ..const import DOMAIN +from ..enums import HacsDispatchEvent + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from ..base import HacsBase + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/list", + vol.Optional("categories"): [str], + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List repositories.""" + hacs: HacsBase = hass.data.get(DOMAIN) + + repositories_data = [] + for repo in hacs.repositories.list_all: + if ( + repo.data.category in msg.get("categories", hacs.common.categories) + and not repo.ignored_by_country_configuration + and repo.data.last_fetched + ): + repositories_data.append( + { + "authors": repo.data.authors, + "available_version": repo.display_available_version, + "installed_version": repo.display_installed_version, + "config_flow": repo.data.config_flow, + "can_download": repo.can_download, + "category": repo.data.category, + "country": repo.repository_manifest.country, + "custom": not hacs.repositories.is_default(str(repo.data.id)), + "description": repo.data.description, + "domain": repo.data.domain, + "downloads": repo.data.downloads, + "file_name": repo.data.file_name, + "full_name": repo.data.full_name, + "hide": repo.data.hide, + "homeassistant": repo.repository_manifest.homeassistant, + "id": repo.data.id, + "installed": repo.data.installed, + "last_updated": repo.data.last_updated, + "local_path": repo.content.path.local, + "name": repo.display_name, + "new": repo.data.new, + "pending_upgrade": repo.pending_update, + "stars": repo.data.stargazers_count, + "state": repo.state, + "status": repo.display_status, + "topics": repo.data.topics, + } + ) + + connection.send_message( + websocket_api.result_message( + msg["id"], + repositories_data, + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/clear_new", + vol.Optional("categories"): cv.ensure_list, + vol.Optional("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_clear_new( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Clear new repositories for specific categories.""" + hacs: HacsBase = hass.data.get(DOMAIN) + + if repo := msg.get("repository"): + repository = hacs.repositories.get_by_id(repo) + repository.data.new = False + + else: + for repo in hacs.repositories.list_all: + if repo.data.new and repo.data.category in msg.get("categories", []): + hacs.log.debug( + "Clearing new flag from '%s'", + repo.data.full_name, + ) + repo.data.new = False + hacs.async_dispatch(HacsDispatchEvent.REPOSITORY, {}) + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/removed", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_removed( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get information about removed repositories.""" + hacs: HacsBase = hass.data.get(DOMAIN) + content = [] + for repo in hacs.repositories.list_removed: + if repo.repository not in hacs.common.ignored_repositories: + content.append(repo.to_json()) + connection.send_message(websocket_api.result_message(msg["id"], content)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/add", + vol.Required("repository"): cv.string, + vol.Required("category"): vol.Lower, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_add( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Add custom repositoriy.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = regex.extract_repository_from_url(msg["repository"]) + category = msg["category"] + + if repository is None: + return + + if repository in hacs.common.skip: + hacs.common.skip.remove(repository) + + if renamed := hacs.common.renamed_repositories.get(repository): + repository = renamed + + if category not in hacs.common.categories: + hacs.log.error("%s is not a valid category for %s", category, repository) + + elif not hacs.repositories.get_by_full_name(repository): + try: + await hacs.async_register_repository( + repository_full_name=repository, + category=category, + ) + + except ( + BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except + ) as exception: + hacs.async_dispatch( + HacsDispatchEvent.ERROR, + { + "action": "add_repository", + "exception": str(sys.exc_info()[0].__name__), + "message": str(exception), + }, + ) + + else: + hacs.async_dispatch( + HacsDispatchEvent.ERROR, + { + "action": "add_repository", + "message": f"Repository '{repository}' exists in the store.", + }, + ) + + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repositories/remove", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repositories_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Remove custom repositoriy.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.remove() + await hacs.data.async_write() + + connection.send_message(websocket_api.result_message(msg["id"], {})) diff --git a/custom_components/hacs/websocket/repository.py b/custom_components/hacs/websocket/repository.py index 70752383242..c1a5902914e 100644 --- a/custom_components/hacs/websocket/repository.py +++ b/custom_components/hacs/websocket/repository.py @@ -1,369 +1,377 @@ -"""Register info websocket commands.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from homeassistant.components import websocket_api -import homeassistant.helpers.config_validation as cv -import voluptuous as vol - -from ..const import DOMAIN -from ..enums import HacsDispatchEvent -from ..exceptions import HacsException -from ..utils.version import version_left_higher_then_right - -if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - - from ..base import HacsBase - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/info", - vol.Required("repository_id"): str, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_info( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Return information about a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository_id = msg["repository_id"] - repository = hacs.repositories.get_by_id(repository_id) - if repository is None: - connection.send_error( - msg["id"], - "repository_not_found", - f"Repository with ID ({repository_id}) not found", - ) - return - - if not repository.updated_info: - try: - await repository.update_repository(ignore_issues=True, force=True) - except Exception as exception: # pylint: disable=broad-except - repository.logger.error("%s %s", repository.string, exception) - repository.updated_info = True - - if repository.data.new: - repository.data.new = False - await hacs.data.async_write() - - connection.send_message( - websocket_api.result_message( - msg["id"], - { - "additional_info": repository.additional_info, - "authors": repository.data.authors, - "available_version": repository.display_available_version, - "beta": repository.data.show_beta, - "can_download": repository.can_download, - "category": repository.data.category, - "config_flow": repository.data.config_flow, - "country": repository.repository_manifest.country, - "custom": not hacs.repositories.is_default(str(repository.data.id)), - "default_branch": repository.data.default_branch, - "description": repository.data.description, - "domain": repository.data.domain, - "downloads": repository.data.downloads, - "file_name": repository.data.file_name, - "full_name": repository.data.full_name, - "hide_default_branch": repository.repository_manifest.hide_default_branch, - "homeassistant": repository.repository_manifest.homeassistant, - "id": repository.data.id, - "installed_version": repository.display_installed_version, - "installed": repository.data.installed, - "issues": repository.data.open_issues, - "last_updated": repository.data.last_updated, - "local_path": repository.content.path.local, - "name": repository.display_name, - "new": False, - "pending_upgrade": repository.pending_update, - "releases": repository.data.published_tags, - "ref": repository.ref, - "selected_tag": repository.data.selected_tag, - "stars": repository.data.stargazers_count, - "state": repository.state, - "status": repository.display_status, - "topics": repository.data.topics, - "version_or_commit": repository.display_version_or_commit, - }, - ) - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/ignore", - vol.Required("repository"): str, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_ignore( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Ignore a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository_id = msg["repository"] - hacs.log.info("Ignoring %s", repository_id) - repository = hacs.repositories.get_by_id(repository_id) - if repository is None: - connection.send_error( - msg["id"], - "repository_not_found", - f"Repository with ID ({repository_id}) not found", - ) - return - - hacs.common.ignored_repositories.add(repository.data.full_name) - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"])) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/state", - vol.Required("repository"): cv.string, - vol.Required("state"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_state( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Set the state of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.state = msg["state"] - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/version", - vol.Required("repository"): cv.string, - vol.Required("version"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_version( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Set the version of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - if msg["version"] == repository.data.default_branch: - repository.data.selected_tag = None - else: - repository.data.selected_tag = msg["version"] - - await repository.update_repository(force=True) - repository.state = None - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/beta", - vol.Required("repository"): cv.string, - vol.Required("show_beta"): cv.boolean, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_beta( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Show or hide beta versions of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.data.show_beta = msg["show_beta"] - - await repository.update_repository(force=True) - repository.state = None - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/download", - vol.Required("repository"): cv.string, - vol.Optional("version"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_download( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Set the version of a repository""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - try: - was_installed = repository.data.installed - await repository.async_download_repository(ref=msg.get("version")) - if not was_installed: - hacs.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True}) - await hacs.async_recreate_entities() - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - except HacsException as exception: - repository.logger.error("%s %s", repository.string, exception) - connection.send_error(msg["id"], "error", str(exception)) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/remove", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_remove( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Remove a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - repository.data.new = False - try: - await repository.update_repository(ignore_issues=True, force=True) - except Exception as exception: # pylint: disable=broad-except - repository.logger.error("%s %s", repository.string, exception) - await repository.uninstall() - - await hacs.data.async_write() - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/refresh", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_refresh( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Refresh a repository.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - await repository.update_repository(ignore_issues=True, force=True) - await hacs.data.async_write() - # Update state of update entity - hacs.coordinators[repository.data.category].async_update_listeners() - - connection.send_message(websocket_api.result_message(msg["id"], {})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/release_notes", - vol.Required("repository"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_release_notes( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Return release notes.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository"]) - - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "name": x.name, - "body": x.body, - "tag": x.tag_name, - } - for x in repository.releases.objects - if not repository.data.installed_version - or version_left_higher_then_right(x.tag_name, repository.data.installed_version) - ], - ) - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "hacs/repository/releases", - vol.Required("repository_id"): cv.string, - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def hacs_repository_releases( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Return releases.""" - hacs: HacsBase = hass.data.get(DOMAIN) - repository = hacs.repositories.get_by_id(msg["repository_id"]) - try: - releases = await repository.async_get_releases() - except Exception as exception: - hacs.log.exception(exception) - connection.send_error(msg["id"], "unknown", str(exception)) - return - - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "name": release.name, - "tag": release.tag_name, - "published_at": release.published_at, - "prerelease": release.prerelease, - } - for release in releases - ], - ) - ) +"""Register info websocket commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components import websocket_api +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from ..const import DOMAIN +from ..enums import HacsDispatchEvent +from ..exceptions import HacsException +from ..utils.version import version_left_higher_then_right + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from ..base import HacsBase + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/info", + vol.Required("repository_id"): str, + vol.Optional("language"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return information about a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository_id = msg["repository_id"] + language = msg.get("language") + repository = hacs.repositories.get_by_id(repository_id) + if repository is None: + connection.send_error( + msg["id"], + "repository_not_found", + f"Repository with ID ({repository_id}) not found", + ) + return + + if not repository.updated_info: + try: + await repository.update_repository(ignore_issues=True, force=True) + except Exception as exception: # pylint: disable=broad-except + repository.logger.error("%s %s", repository.string, exception) + repository.updated_info = True + + if repository.data.new: + repository.data.new = False + await hacs.data.async_write() + + additional_info = repository.additional_info + if language: + additional_info = await repository.async_get_info_file_contents_with_language( + language=language + ) + + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "additional_info": additional_info, + "authors": repository.data.authors, + "available_version": repository.display_available_version, + "beta": repository.data.show_beta, + "can_download": repository.can_download, + "category": repository.data.category, + "config_flow": repository.data.config_flow, + "country": repository.repository_manifest.country, + "custom": not hacs.repositories.is_default(str(repository.data.id)), + "default_branch": repository.data.default_branch, + "description": repository.data.description, + "domain": repository.data.domain, + "downloads": repository.data.downloads, + "file_name": repository.data.file_name, + "full_name": repository.data.full_name, + "hide_default_branch": repository.repository_manifest.hide_default_branch, + "homeassistant": repository.repository_manifest.homeassistant, + "id": repository.data.id, + "installed_version": repository.display_installed_version, + "installed": repository.data.installed, + "issues": repository.data.open_issues, + "last_updated": repository.data.last_updated, + "local_path": repository.content.path.local, + "name": repository.display_name, + "new": False, + "pending_upgrade": repository.pending_update, + "releases": repository.data.published_tags, + "ref": repository.ref, + "selected_tag": repository.data.selected_tag, + "stars": repository.data.stargazers_count, + "state": repository.state, + "status": repository.display_status, + "topics": repository.data.topics, + "version_or_commit": repository.display_version_or_commit, + }, + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/ignore", + vol.Required("repository"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_ignore( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Ignore a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository_id = msg["repository"] + hacs.log.info("Ignoring %s", repository_id) + repository = hacs.repositories.get_by_id(repository_id) + if repository is None: + connection.send_error( + msg["id"], + "repository_not_found", + f"Repository with ID ({repository_id}) not found", + ) + return + + hacs.common.ignored_repositories.add(repository.data.full_name) + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"])) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/state", + vol.Required("repository"): cv.string, + vol.Required("state"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_state( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the state of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.state = msg["state"] + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/version", + vol.Required("repository"): cv.string, + vol.Required("version"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_version( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the version of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + if msg["version"] == repository.data.default_branch: + repository.data.selected_tag = None + else: + repository.data.selected_tag = msg["version"] + + await repository.update_repository(force=True) + repository.state = None + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/beta", + vol.Required("repository"): cv.string, + vol.Required("show_beta"): cv.boolean, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_beta( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Show or hide beta versions of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.data.show_beta = msg["show_beta"] + + await repository.update_repository(force=True) + repository.state = None + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/download", + vol.Required("repository"): cv.string, + vol.Optional("version"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_download( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the version of a repository""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + try: + was_installed = repository.data.installed + await repository.async_download_repository(ref=msg.get("version")) + if not was_installed: + hacs.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True}) + await hacs.async_recreate_entities() + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + except HacsException as exception: + repository.logger.error("%s %s", repository.string, exception) + connection.send_error(msg["id"], "error", str(exception)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/remove", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Remove a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + repository.data.new = False + try: + await repository.update_repository(ignore_issues=True, force=True) + except Exception as exception: # pylint: disable=broad-except + repository.logger.error("%s %s", repository.string, exception) + await repository.uninstall() + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/refresh", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_refresh( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Refresh a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + await repository.update_repository(ignore_issues=True, force=True) + await hacs.data.async_write() + # Update state of update entity + hacs.coordinators[repository.data.category].async_update_listeners() + + connection.send_message(websocket_api.result_message(msg["id"], {})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/release_notes", + vol.Required("repository"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_release_notes( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return release notes.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository"]) + + connection.send_message( + websocket_api.result_message( + msg["id"], + [ + { + "name": x.name, + "body": x.body, + "tag": x.tag_name, + } + for x in repository.releases.objects + if not repository.data.installed_version + or version_left_higher_then_right(x.tag_name, repository.data.installed_version) + ], + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/releases", + vol.Required("repository_id"): cv.string, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_repository_releases( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return releases.""" + hacs: HacsBase = hass.data.get(DOMAIN) + repository = hacs.repositories.get_by_id(msg["repository_id"]) + try: + releases = await repository.async_get_releases() + except Exception as exception: + hacs.log.exception(exception) + connection.send_error(msg["id"], "unknown", str(exception)) + return + + connection.send_message( + websocket_api.result_message( + msg["id"], + [ + { + "name": release.name, + "tag": release.tag_name, + "published_at": release.published_at, + "prerelease": release.prerelease, + } + for release in releases + ], + ) + )