diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbff2d2982e..6b97628446d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,6 @@ jobs: - name: "Set version number" run: | - sed -i '/INTEGRATION_VERSION = /c\INTEGRATION_VERSION = "${{ steps.version.outputs.version }}"' ${{ github.workspace }}/custom_components/hacs/const.py python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} # Pack the HACS dir as a zip and upload to the release diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index 2bdaf052121..301d8e60124 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -2,23 +2,39 @@ from __future__ import annotations import logging +import math import pathlib from dataclasses import asdict, dataclass, field from typing import TYPE_CHECKING, Any -from aiogithubapi import GitHub, GitHubAPI +from aiogithubapi import ( + GitHub, + GitHubAPI, + GitHubAuthenticationException, + GitHubRatelimitException, +) from aiogithubapi.objects.repository import AIOGitHubAPIRepository from aiohttp.client import ClientSession from awesomeversion import AwesomeVersion from homeassistant.core import HomeAssistant - -from .const import INTEGRATION_VERSION -from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode +from homeassistant.loader import Integration +from queueman.manager import QueueManager + +from .enums import ( + ConfigurationType, + HacsCategory, + HacsDisabledReason, + HacsStage, + LovelaceMode, +) from .exceptions import HacsException from .utils.logger import getLogger if TYPE_CHECKING: + from .hacsbase.data import HacsData from .helpers.classes.repository import HacsRepository + from .operational.factory import HacsTaskFactory + from .tasks.manager import HacsTaskManager @dataclass @@ -87,7 +103,7 @@ class HacsCore: class HacsCommon: """Common for HACS.""" - categories: list[str] = field(default_factory=list) + categories: set[str] = field(default_factory=set) default: list[str] = field(default_factory=list) installed: list[str] = field(default_factory=list) renamed_repositories: dict[str, str] = field(default_factory=dict) @@ -113,7 +129,6 @@ class HacsSystem: disabled: bool = False disabled_reason: str | None = None running: bool = False - version = AwesomeVersion(INTEGRATION_VERSION) stage = HacsStage.SETUP action: bool = False @@ -124,29 +139,45 @@ class HacsBase: _repositories = [] _repositories_by_full_name = {} _repositories_by_id = {} + common = HacsCommon() configuration = HacsConfiguration() core = HacsCore() - data = None + data: HacsData | None = None data_repo: AIOGitHubAPIRepository | None = None + factory: HacsTaskFactory | None = None frontend = HacsFrontend() github: GitHub | None = None githubapi: GitHubAPI | None = None hass: HomeAssistant | None = None + integration: Integration | None = None log: logging.Logger = getLogger() + queue: QueueManager | None = None recuring_tasks = [] repositories: list[HacsRepository] = [] repository: AIOGitHubAPIRepository | None = None session: ClientSession | None = None - stage = HacsStage.SETUP + stage: HacsStage | None = None status = HacsStatus() system = HacsSystem() - version: AwesomeVersion | None = None + tasks: HacsTaskManager | None = None + version: str | None = None @property def integration_dir(self) -> pathlib.Path: """Return the HACS integration dir.""" - return pathlib.Path(__file__).parent + return self.integration.file_path + + async def async_set_stage(self, stage: HacsStage | None) -> None: + """Set HACS stage.""" + if stage and self.stage == stage: + return + + self.stage = stage + if stage is not None: + self.log.info("Stage changed: %s", self.stage) + self.hass.bus.async_fire("hacs/stage", {"stage": self.stage}) + await self.tasks.async_execute_runtume_tasks() def disable_hacs(self, reason: HacsDisabledReason) -> None: """Disable HACS.""" @@ -160,3 +191,32 @@ def enable_hacs(self) -> None: self.system.disabled = False self.system.disabled_reason = None self.log.info("HACS is enabled") + + def enable_hacs_category(self, category: HacsCategory): + """Enable HACS category.""" + if category not in self.common.categories: + self.log.info("Enable category: %s", category) + self.common.categories.add(category) + + def disable_hacs_category(self, category: HacsCategory): + """Disable HACS category.""" + if category in self.common.categories: + self.log.info("Disabling category: %s", category) + self.common.categories.pop(category) + + async def async_can_update(self) -> int: + """Helper to calculate the number of repositories we can fetch data for.""" + try: + result = await self.githubapi.rate_limit() + if ((limit := result.data.resources.core.remaining or 0) - 1000) >= 15: + return math.floor((limit - 1000) / 15) + except GitHubAuthenticationException as exception: + self.log.error("GitHub authentication failed - %s", exception) + self.disable_hacs(HacsDisabledReason.INVALID_TOKEN) + except GitHubRatelimitException as exception: + self.log.error("GitHub API ratelimited - %s", exception) + self.disable_hacs(HacsDisabledReason.RATE_LIMIT) + except BaseException as exception: # pylint: disable=broad-except + self.log.exception(exception) + + return 0 diff --git a/custom_components/hacs/config_flow.py b/custom_components/hacs/config_flow.py index d5335a5f944..020ce887a09 100644 --- a/custom_components/hacs/config_flow.py +++ b/custom_components/hacs/config_flow.py @@ -8,13 +8,9 @@ from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.event import async_call_later +from homeassistant.loader import async_get_integration -from custom_components.hacs.const import ( - CLIENT_ID, - DOMAIN, - INTEGRATION_VERSION, - MINIMUM_HA_VERSION, -) +from custom_components.hacs.const import CLIENT_ID, DOMAIN, MINIMUM_HA_VERSION from custom_components.hacs.enums import ConfigurationType from custom_components.hacs.helpers.functions.configuration_schema import ( RELEASE_LIMIT, @@ -72,11 +68,12 @@ async def _wait_for_activation(_=None): ) if not self.activation: + integration = await async_get_integration(self.hass, DOMAIN) if not self.device: self.device = GitHubDeviceAPI( client_id=CLIENT_ID, session=aiohttp_client.async_get_clientsession(self.hass), - **{"client_name": f"HACS/{INTEGRATION_VERSION}"}, + **{"client_name": f"HACS/{integration.version}"}, ) async_call_later(self.hass, 1, _wait_for_activation) try: diff --git a/custom_components/hacs/const.py b/custom_components/hacs/const.py index fd8fdba7ce6..5b69e901651 100644 --- a/custom_components/hacs/const.py +++ b/custom_components/hacs/const.py @@ -3,28 +3,17 @@ NAME_LONG = "HACS (Home Assistant Community Store)" NAME_SHORT = "HACS" -INTEGRATION_VERSION = "main" DOMAIN = "hacs" CLIENT_ID = "395a8e669c5de9f7c6e8" MINIMUM_HA_VERSION = "2021.2.0" PROJECT_URL = "https://github.com/hacs/integration/" -CUSTOM_UPDATER_LOCATIONS = [ - "{}/custom_components/custom_updater.py", - "{}/custom_components/custom_updater/__init__.py", -] + ISSUE_URL = f"{PROJECT_URL}issues" DOMAIN_DATA = f"{NAME_SHORT.lower()}_data" -ELEMENT_TYPES = ["integration", "plugin"] - PACKAGE_NAME = "custom_components.hacs" -HACS_GITHUB_API_HEADERS = { - "User-Agent": f"HACS/{INTEGRATION_VERSION}", - "Accept": ACCEPT_HEADERS["preview"], -} - HACS_ACTION_GITHUB_API_HEADERS = { "User-Agent": "HACS/action", "Accept": ACCEPT_HEADERS["preview"], @@ -44,16 +33,11 @@ # Messages NO_ELEMENTS = "No elements to show, open the store to install some awesome stuff." -CUSTOM_UPDATER_WARNING = """ -This cannot be used with custom_updater. -To use this you need to remove custom_updater form {} -""" - -STARTUP = f""" +STARTUP = """ ------------------------------------------------------------------- HACS (Home Assistant Community Store) -Version: {INTEGRATION_VERSION} +Version: {version} This is a custom integration If you have any issues with this you need to open an issue here: https://github.com/hacs/integration/issues diff --git a/custom_components/hacs/enums.py b/custom_components/hacs/enums.py index e03c93de4b9..2156a47e14f 100644 --- a/custom_components/hacs/enums.py +++ b/custom_components/hacs/enums.py @@ -28,6 +28,16 @@ class LovelaceMode(str, Enum): YAML = "yaml" +class HacsTaskType(str, Enum): + """HacsTaskType""" + + RUNTIME = "runtime" + EVENT = "event" + SCHEDULE = "schedule" + MANUAL = "manual" + BASE = "base" + + class HacsStage(str, Enum): SETUP = "setup" STARTUP = "startup" diff --git a/custom_components/hacs/hacsbase/data.py b/custom_components/hacs/hacsbase/data.py index 7548405b93b..27b3e0fa1dd 100644 --- a/custom_components/hacs/hacsbase/data.py +++ b/custom_components/hacs/hacsbase/data.py @@ -4,7 +4,6 @@ from homeassistant.core import callback -from custom_components.hacs.const import INTEGRATION_VERSION from custom_components.hacs.helpers.classes.manifest import HacsManifest from custom_components.hacs.helpers.functions.register_repository import ( register_repository, @@ -207,7 +206,7 @@ def async_restore_repository(self, entry, repository_data): repository.status.first_install = False if repository_data["full_name"] == "hacs/integration": - repository.data.installed_version = INTEGRATION_VERSION + repository.data.installed_version = self.hacs.version repository.data.installed = True return True diff --git a/custom_components/hacs/hacsbase/hacs.py b/custom_components/hacs/hacsbase/hacs.py index 9844e070dfb..49d5dbd9e3c 100644 --- a/custom_components/hacs/hacsbase/hacs.py +++ b/custom_components/hacs/hacsbase/hacs.py @@ -13,19 +13,12 @@ from custom_components.hacs.helpers.functions.register_repository import ( register_repository, ) -from custom_components.hacs.helpers.functions.remaining_github_calls import ( - get_fetch_updates_for, -) + from custom_components.hacs.helpers.functions.store import ( async_load_from_store, async_save_to_store, ) -from custom_components.hacs.operational.setup_actions.categories import ( - async_setup_extra_stores, -) from custom_components.hacs.share import ( - get_factory, - get_queue, get_removed, is_removed, list_removed_repositories, @@ -33,6 +26,7 @@ from ..base import HacsBase from ..enums import HacsCategory, HacsStage +from ..share import get_factory, get_queue class Hacs(HacsBase, HacsHelpers): @@ -121,7 +115,6 @@ async def startup_tasks(self, _event=None): """Tasks that are started after startup.""" await self.async_set_stage(HacsStage.STARTUP) self.status.background_task = True - await async_setup_extra_stores() self.hass.bus.async_fire("hacs/status", {}) await self.handle_critical_repositories_startup() @@ -241,15 +234,13 @@ async def prosess_queue(self, _notarealarg=None): self.log.debug("Queue is already running") return - can_update = await get_fetch_updates_for(self.githubapi) + can_update = await self.async_can_update() self.log.debug( "Can update %s repositories, items in queue %s", can_update, self.queue.pending_tasks, ) - if can_update == 0: - self.log.info("HACS is ratelimited, repository updates will resume later.") - else: + if can_update != 0: self.status.background_task = True self.hass.bus.async_fire("hacs/status", {}) try: @@ -283,7 +274,6 @@ async def recurring_tasks_installed(self, _notarealarg=None): async def recurring_tasks_all(self, _notarealarg=None): """Recurring tasks for all repositories.""" self.log.debug("Starting recurring background task for all repositories") - await async_setup_extra_stores() self.status.background_task = True self.hass.bus.async_fire("hacs/status", {}) @@ -351,9 +341,3 @@ async def async_get_category_repositories(self, category: HacsCategory): continue continue self.queue.add(self.factory.safe_register(repo, category)) - - async def async_set_stage(self, stage: str) -> None: - """Set the stage of HACS.""" - self.stage = HacsStage(stage) - self.log.info("Stage changed: %s", self.stage) - self.hass.bus.async_fire("hacs/stage", {"stage": self.stage}) diff --git a/custom_components/hacs/helpers/functions/constrains.py b/custom_components/hacs/helpers/functions/constrains.py deleted file mode 100644 index bee3db8ff89..00000000000 --- a/custom_components/hacs/helpers/functions/constrains.py +++ /dev/null @@ -1,43 +0,0 @@ -"""HACS Startup constrains.""" -# pylint: disable=bad-continuation -import os - -from custom_components.hacs.const import ( - CUSTOM_UPDATER_LOCATIONS, - CUSTOM_UPDATER_WARNING, - MINIMUM_HA_VERSION, -) -from custom_components.hacs.helpers.functions.misc import version_left_higher_then_right -from custom_components.hacs.share import get_hacs - - -def check_constrains(): - """Check HACS constrains.""" - if not constrain_custom_updater(): - return False - if not constrain_version(): - return False - return True - - -def constrain_custom_updater(): - """Check if custom_updater exist.""" - hacs = get_hacs() - for location in CUSTOM_UPDATER_LOCATIONS: - if os.path.exists(location.format(hacs.core.config_path)): - msg = CUSTOM_UPDATER_WARNING.format(location.format(hacs.core.config_path)) - hacs.log.critical(msg) - return False - return True - - -def constrain_version(): - """Check if the version is valid.""" - hacs = get_hacs() - if not version_left_higher_then_right(hacs.core.ha_version, MINIMUM_HA_VERSION): - hacs.log.critical( - "You need HA version %s or newer to use this integration.", - MINIMUM_HA_VERSION, - ) - return False - return True diff --git a/custom_components/hacs/helpers/functions/information.py b/custom_components/hacs/helpers/functions/information.py index 2a8fb707743..ea69f970246 100644 --- a/custom_components/hacs/helpers/functions/information.py +++ b/custom_components/hacs/helpers/functions/information.py @@ -2,8 +2,8 @@ import json from aiogithubapi import AIOGitHubAPIException, AIOGitHubAPINotModifiedException, GitHub +from aiogithubapi.const import ACCEPT_HEADERS -from custom_components.hacs.const import HACS_GITHUB_API_HEADERS from custom_components.hacs.exceptions import HacsException, HacsNotModifiedException from custom_components.hacs.helpers.functions.template import render_template from custom_components.hacs.share import get_hacs @@ -45,11 +45,15 @@ async def get_info_md_content(repository): async def get_repository(session, token, repository_full_name, etag=None): """Return a repository object or None.""" + hacs = get_hacs() try: github = GitHub( token, session, - headers=HACS_GITHUB_API_HEADERS, + headers={ + "User-Agent": f"HACS/{hacs.version}", + "Accept": ACCEPT_HEADERS["preview"], + }, ) repository = await github.get_repo(repository_full_name, etag) return repository, github.client.last_response.etag diff --git a/custom_components/hacs/helpers/functions/misc.py b/custom_components/hacs/helpers/functions/misc.py index 5114d6b5000..f83560ee838 100644 --- a/custom_components/hacs/helpers/functions/misc.py +++ b/custom_components/hacs/helpers/functions/misc.py @@ -1,8 +1,7 @@ """Helper functions: misc""" import re -from functools import lru_cache -from awesomeversion import AwesomeVersion +from ...utils import version RE_REPOSITORY = re.compile( r"(?:(?:.*github.com.)|^)([A-Za-z0-9-]+\/[\w.-]+?)(?:(?:\.git)?|(?:[^\w.-].*)?)$" @@ -28,10 +27,9 @@ def get_repository_name(repository) -> str: ) -@lru_cache(maxsize=1024) def version_left_higher_then_right(left: str, right: str) -> bool: """Return a bool if source is newer than target, will also be true if identical.""" - return AwesomeVersion(left) >= AwesomeVersion(right) + return version.version_left_higher_then_right(left, right) def extract_repository_from_url(url: str) -> str or None: diff --git a/custom_components/hacs/helpers/functions/remaining_github_calls.py b/custom_components/hacs/helpers/functions/remaining_github_calls.py deleted file mode 100644 index 000726c22f2..00000000000 --- a/custom_components/hacs/helpers/functions/remaining_github_calls.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Helper to calculate the remaining calls to github.""" -import math - -from aiogithubapi import GitHubAPI, GitHubAuthenticationException - -from custom_components.hacs.utils.logger import getLogger - -_LOGGER = getLogger() - -RATE_LIMIT_THRESHOLD = 1000 -CALLS_PR_REPOSITORY = 15 - - -async def remaining(github: GitHubAPI): - """Helper to calculate the remaining calls to github.""" - try: - result = await github.rate_limit() - except GitHubAuthenticationException as exception: - _LOGGER.error(f"GitHub authentication failed - {exception}") - return None - except BaseException as exception: # pylint: disable=broad-except - _LOGGER.error(exception) - return 0 - - return result.data.resources.core.remaining or 0 - - -async def get_fetch_updates_for(github: GitHubAPI): - """Helper to calculate the number of repositories we can fetch data for.""" - if (limit := await remaining(github)) is None: - return None - - if limit - RATE_LIMIT_THRESHOLD <= CALLS_PR_REPOSITORY: - return 0 - return math.floor((limit - RATE_LIMIT_THRESHOLD) / CALLS_PR_REPOSITORY) diff --git a/custom_components/hacs/operational/runtime.py b/custom_components/hacs/operational/runtime.py deleted file mode 100644 index 95f5985b1d9..00000000000 --- a/custom_components/hacs/operational/runtime.py +++ /dev/null @@ -1 +0,0 @@ -"""Runtime...""" diff --git a/custom_components/hacs/operational/setup.py b/custom_components/hacs/operational/setup.py index bffec5f3e50..8c3255502c5 100644 --- a/custom_components/hacs/operational/setup.py +++ b/custom_components/hacs/operational/setup.py @@ -1,20 +1,16 @@ """Setup HACS.""" from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI -from awesomeversion import AwesomeVersion +from aiogithubapi.const import ACCEPT_HEADERS from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import __version__ as HAVERSION -from homeassistant.core import Config, CoreState +from homeassistant.core import CoreState from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_call_later +from homeassistant.loader import async_get_integration -from custom_components.hacs.const import ( - DOMAIN, - HACS_GITHUB_API_HEADERS, - INTEGRATION_VERSION, - STARTUP, -) +from custom_components.hacs.const import DOMAIN, STARTUP from custom_components.hacs.enums import ( ConfigurationType, HacsDisabledReason, @@ -22,26 +18,12 @@ LovelaceMode, ) from custom_components.hacs.hacsbase.data import HacsData -from custom_components.hacs.helpers.functions.constrains import check_constrains -from custom_components.hacs.helpers.functions.remaining_github_calls import ( - get_fetch_updates_for, -) + from custom_components.hacs.operational.reload import async_reload_entry from custom_components.hacs.operational.remove import async_remove_entry -from custom_components.hacs.operational.setup_actions.clear_storage import ( - async_clear_storage, -) -from custom_components.hacs.operational.setup_actions.frontend import ( - async_setup_frontend, -) -from custom_components.hacs.operational.setup_actions.load_hacs_repository import ( - async_load_hacs_repository, -) -from custom_components.hacs.operational.setup_actions.sensor import async_add_sensor -from custom_components.hacs.operational.setup_actions.websocket_api import ( - async_setup_hacs_websockt_api, -) + from custom_components.hacs.share import get_hacs +from custom_components.hacs.tasks.manager import HacsTaskManager try: from homeassistant.components.lovelace import system_health_info @@ -51,10 +33,58 @@ async def _async_common_setup(hass): """Common setup stages.""" + integration = await async_get_integration(hass, DOMAIN) + hacs = get_hacs() + + hacs.enable_hacs() + await hacs.async_set_stage(None) + + hacs.log.info(STARTUP.format(version=integration.version)) + + hacs.integration = integration + hacs.version = integration.version hacs.hass = hass + hacs.data = HacsData() hacs.system.running = True hacs.session = async_create_clientsession(hass) + hacs.tasks = HacsTaskManager() + + try: + lovelace_info = await system_health_info(hacs.hass) + except (TypeError, KeyError, HomeAssistantError): + # If this happens, the users YAML is not valid, we assume YAML mode + lovelace_info = {"mode": "yaml"} + hacs.log.debug(f"Configuration type: {hacs.configuration.config_type}") + hacs.core.config_path = hacs.hass.config.path() + hacs.core.ha_version = HAVERSION + + hacs.core.lovelace_mode = lovelace_info.get("mode", "yaml") + hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml")) + + await hacs.tasks.async_load() + + # Setup session for API clients + session = async_create_clientsession(hacs.hass) + + ## Legacy GitHub client + hacs.github = GitHub( + hacs.configuration.token, + session, + headers={ + "User-Agent": f"HACS/{hacs.version}", + "Accept": ACCEPT_HEADERS["preview"], + }, + ) + + ## New GitHub client + hacs.githubapi = GitHubAPI( + token=hacs.configuration.token, + session=session, + **{"client_name": f"HACS/{hacs.version}"}, + ) + + hass.data[DOMAIN] = hacs async def async_setup_entry(hass, config_entry): @@ -67,8 +97,6 @@ async def async_setup_entry(hass, config_entry): if hass.data.get(DOMAIN) is not None: return False - await _async_common_setup(hass) - hacs.configuration.update_from_dict( { "config_entry": config_entry, @@ -78,6 +106,7 @@ async def async_setup_entry(hass, config_entry): } ) + await _async_common_setup(hass) return await async_startup_wrapper_for_config_entry() @@ -89,8 +118,6 @@ async def async_setup(hass, config): if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY: return True - await _async_common_setup(hass) - hacs.configuration.update_from_dict( { "config_type": ConfigurationType.YAML, @@ -98,6 +125,8 @@ async def async_setup(hass, config): "config": config[DOMAIN], } ) + + await _async_common_setup(hass) await async_startup_wrapper_for_yaml() return True @@ -135,91 +164,13 @@ async def async_startup_wrapper_for_yaml(_=None): async def async_hacs_startup(): """HACS startup tasks.""" hacs = get_hacs() - hacs.hass.data[DOMAIN] = hacs - - try: - lovelace_info = await system_health_info(hacs.hass) - except (TypeError, KeyError, HomeAssistantError): - # If this happens, the users YAML is not valid, we assume YAML mode - lovelace_info = {"mode": "yaml"} - hacs.log.debug(f"Configuration type: {hacs.configuration.config_type}") - hacs.version = INTEGRATION_VERSION - hacs.log.info(STARTUP) - hacs.core.config_path = hacs.hass.config.path() - hacs.core.ha_version = HAVERSION - hacs.core.lovelace_mode = lovelace_info.get("mode", "yaml") - hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml")) - - # Setup websocket API - await async_setup_hacs_websockt_api() - - # Set up frontend - await async_setup_frontend() - - # Clear old storage files - await async_clear_storage() - - # Setup GitHub API clients - session = async_create_clientsession(hacs.hass) - - ## Legacy client - hacs.github = GitHub( - hacs.configuration.token, - session, - headers=HACS_GITHUB_API_HEADERS, - ) - - ## New GitHub client - hacs.githubapi = GitHubAPI( - token=hacs.configuration.token, - session=session, - **{"client_name": f"HACS/{INTEGRATION_VERSION}"}, - ) - - hacs.data = HacsData() - - hacs.enable_hacs() - - can_update = await get_fetch_updates_for(hacs.githubapi) - if can_update is None: - hacs.log.critical("Your GitHub token is not valid") - hacs.disable_hacs(HacsDisabledReason.INVALID_TOKEN) - return False - - if can_update != 0: - hacs.log.debug(f"Can update {can_update} repositories") - else: - hacs.log.error( - "Your GitHub account has been ratelimited, HACS will resume when the limit is cleared" - ) - hacs.disable_hacs(HacsDisabledReason.RATE_LIMIT) - return False - - # Check HACS Constrains - if not await hacs.hass.async_add_executor_job(check_constrains): - if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY: - if hacs.configuration.config_entry is not None: - await async_remove_entry(hacs.hass, hacs.configuration.config_entry) - hacs.disable_hacs(HacsDisabledReason.CONSTRAINS) + await hacs.async_set_stage(HacsStage.SETUP) + if hacs.system.disabled: return False - # Load HACS - if not await async_load_hacs_repository(): - if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY: - if hacs.configuration.config_entry is not None: - await async_remove_entry(hacs.hass, hacs.configuration.config_entry) - hacs.disable_hacs(HacsDisabledReason.LOAD_HACS) - return False - - # Restore from storefiles - if not await hacs.data.restore(): - hacs_repo = hacs.get_by_name("hacs/integration") - hacs_repo.pending_restart = True - if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY: - if hacs.configuration.config_entry is not None: - await async_remove_entry(hacs.hass, hacs.configuration.config_entry) - hacs.disable_hacs(HacsDisabledReason.RESTORE) + await hacs.async_set_stage(HacsStage.STARTUP) + if hacs.system.disabled: return False # Setup startup tasks @@ -228,12 +179,10 @@ async def async_hacs_startup(): else: hacs.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, hacs.startup_tasks) - # Set up sensor - await async_add_sensor() - # Mischief managed! await hacs.async_set_stage(HacsStage.WAITING) hacs.log.info( "Setup complete, waiting for Home Assistant before startup tasks starts" ) - return True + + return not hacs.system.disabled diff --git a/custom_components/hacs/operational/setup_actions/__init__.py b/custom_components/hacs/operational/setup_actions/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/custom_components/hacs/operational/setup_actions/categories.py b/custom_components/hacs/operational/setup_actions/categories.py deleted file mode 100644 index 5092f66a829..00000000000 --- a/custom_components/hacs/operational/setup_actions/categories.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Starting setup task: extra stores.""" -from custom_components.hacs.const import ELEMENT_TYPES - -from ...enums import HacsCategory, HacsSetupTask -from ...share import get_hacs - - -def _setup_extra_stores(): - """Set up extra stores in HACS if enabled in Home Assistant.""" - hacs = get_hacs() - hacs.log.debug("Starting setup task: Extra stores") - hacs.common.categories = set() - for category in ELEMENT_TYPES: - enable_category(hacs, HacsCategory(category)) - - if HacsCategory.PYTHON_SCRIPT in hacs.hass.config.components: - enable_category(hacs, HacsCategory.PYTHON_SCRIPT) - - if ( - hacs.hass.services._services.get("frontend", {}).get("reload_themes") - is not None - ): - enable_category(hacs, HacsCategory.THEME) - - if hacs.configuration.appdaemon: - enable_category(hacs, HacsCategory.APPDAEMON) - if hacs.configuration.netdaemon: - enable_category(hacs, HacsCategory.NETDAEMON) - - -async def async_setup_extra_stores(): - """Async wrapper for setup_extra_stores""" - hacs = get_hacs() - hacs.log.info("setup task %s", HacsSetupTask.CATEGORIES) - await hacs.hass.async_add_executor_job(_setup_extra_stores) - - -def enable_category(hacs, category: HacsCategory): - """Add category.""" - if category not in hacs.common.categories: - hacs.log.info("Enable category: %s", category) - hacs.common.categories.add(category) diff --git a/custom_components/hacs/operational/setup_actions/clear_storage.py b/custom_components/hacs/operational/setup_actions/clear_storage.py deleted file mode 100644 index c67aaafac44..00000000000 --- a/custom_components/hacs/operational/setup_actions/clear_storage.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Starting setup task: clear storage.""" -import os - -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -async def async_clear_storage(): - """Async wrapper for clear_storage""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.CATEGORIES) - await hacs.hass.async_add_executor_job(_clear_storage) - - -def _clear_storage(): - """Clear old files from storage.""" - hacs = get_hacs() - storagefiles = ["hacs"] - for s_f in storagefiles: - path = f"{hacs.core.config_path}/.storage/{s_f}" - if os.path.isfile(path): - hacs.log.info(f"Cleaning up old storage file {path}") - os.remove(path) diff --git a/custom_components/hacs/operational/setup_actions/frontend.py b/custom_components/hacs/operational/setup_actions/frontend.py deleted file mode 100644 index d08be647f4b..00000000000 --- a/custom_components/hacs/operational/setup_actions/frontend.py +++ /dev/null @@ -1,77 +0,0 @@ -from hacs_frontend import locate_dir -from hacs_frontend.version import VERSION as FE_VERSION - -from custom_components.hacs.helpers.functions.information import get_frontend_version -from custom_components.hacs.share import get_hacs -from custom_components.hacs.utils.logger import getLogger -from custom_components.hacs.webresponses.frontend import HacsFrontendDev - -from ...enums import HacsSetupTask - -URL_BASE = "/hacsfiles" - - -async def async_setup_frontend(): - """Configure the HACS frontend elements.""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.FRONTEND) - hass = hacs.hass - - # Register themes - hass.http.register_static_path(f"{URL_BASE}/themes", hass.config.path("themes")) - - # Register frontend - if hacs.configuration.frontend_repo_url: - getLogger().warning( - "Frontend development mode enabled. Do not run in production." - ) - hass.http.register_view(HacsFrontendDev()) - else: - # - hass.http.register_static_path( - f"{URL_BASE}/frontend", locate_dir(), cache_headers=False - ) - - # Custom iconset - hass.http.register_static_path( - f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js") - ) - if "frontend_extra_module_url" not in hass.data: - hass.data["frontend_extra_module_url"] = set() - hass.data["frontend_extra_module_url"].add("/hacsfiles/iconset.js") - - # Register www/community for all other files - use_cache = hacs.core.lovelace_mode == "storage" - hacs.log.info( - "%s mode, cache for /hacsfiles/: %s", - hacs.core.lovelace_mode, - use_cache, - ) - hass.http.register_static_path( - URL_BASE, - hass.config.path("www/community"), - cache_headers=use_cache, - ) - - hacs.frontend.version_running = FE_VERSION - hacs.frontend.version_expected = await hass.async_add_executor_job( - get_frontend_version - ) - - # Add to sidepanel - if "hacs" not in hass.data.get("frontend_panels", {}): - hass.components.frontend.async_register_built_in_panel( - component_name="custom", - sidebar_title=hacs.configuration.sidepanel_title, - sidebar_icon=hacs.configuration.sidepanel_icon, - frontend_url_path="hacs", - config={ - "_panel_custom": { - "name": "hacs-frontend", - "embed_iframe": True, - "trust_external": False, - "js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={FE_VERSION}", - } - }, - require_admin=True, - ) diff --git a/custom_components/hacs/operational/setup_actions/load_hacs_repository.py b/custom_components/hacs/operational/setup_actions/load_hacs_repository.py deleted file mode 100644 index 7d1f5cfbbce..00000000000 --- a/custom_components/hacs/operational/setup_actions/load_hacs_repository.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Starting setup task: load HACS repository.""" -from custom_components.hacs.const import INTEGRATION_VERSION -from custom_components.hacs.exceptions import HacsException -from custom_components.hacs.helpers.functions.information import get_repository -from custom_components.hacs.helpers.functions.register_repository import ( - register_repository, -) -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -async def async_load_hacs_repository(): - """Load HACS repositroy.""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.HACS_REPO) - - try: - repository = hacs.get_by_name("hacs/integration") - if repository is None: - await register_repository("hacs/integration", "integration") - repository = hacs.get_by_name("hacs/integration") - if repository is None: - raise HacsException("Unknown error") - repository.data.installed = True - repository.data.installed_version = INTEGRATION_VERSION - repository.data.new = False - hacs.repository = repository.repository_object - hacs.data_repo, _ = await get_repository( - hacs.session, hacs.configuration.token, "hacs/default", None - ) - except HacsException as exception: - if "403" in f"{exception}": - hacs.log.critical("GitHub API is ratelimited, or the token is wrong.") - else: - hacs.log.critical(f"[{exception}] - Could not load HACS!") - return False - return True diff --git a/custom_components/hacs/operational/setup_actions/sensor.py b/custom_components/hacs/operational/setup_actions/sensor.py deleted file mode 100644 index a7daae511e3..00000000000 --- a/custom_components/hacs/operational/setup_actions/sensor.py +++ /dev/null @@ -1,25 +0,0 @@ -""""Starting setup task: Sensor".""" -from homeassistant.helpers import discovery - -from custom_components.hacs.const import DOMAIN -from custom_components.hacs.share import get_hacs - -from ...enums import ConfigurationType, HacsSetupTask - - -async def async_add_sensor(): - """Async wrapper for add sensor""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.SENSOR) - if hacs.configuration.config_type == ConfigurationType.YAML: - hacs.hass.async_create_task( - discovery.async_load_platform( - hacs.hass, "sensor", DOMAIN, {}, hacs.configuration.config - ) - ) - else: - hacs.hass.async_add_job( - hacs.hass.config_entries.async_forward_entry_setup( - hacs.configuration.config_entry, "sensor" - ) - ) diff --git a/custom_components/hacs/operational/setup_actions/websocket_api.py b/custom_components/hacs/operational/setup_actions/websocket_api.py deleted file mode 100644 index cf3e5b3a9e7..00000000000 --- a/custom_components/hacs/operational/setup_actions/websocket_api.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Register WS API endpoints for HACS.""" -from homeassistant.components import websocket_api - -from custom_components.hacs.api.acknowledge_critical_repository import ( - acknowledge_critical_repository, -) -from custom_components.hacs.api.check_local_path import check_local_path -from custom_components.hacs.api.get_critical_repositories import ( - get_critical_repositories, -) -from custom_components.hacs.api.hacs_config import hacs_config -from custom_components.hacs.api.hacs_removed import hacs_removed -from custom_components.hacs.api.hacs_repositories import hacs_repositories -from custom_components.hacs.api.hacs_repository import hacs_repository -from custom_components.hacs.api.hacs_repository_data import hacs_repository_data -from custom_components.hacs.api.hacs_settings import hacs_settings -from custom_components.hacs.api.hacs_status import hacs_status -from custom_components.hacs.share import get_hacs - -from ...enums import HacsSetupTask - - -async def async_setup_hacs_websockt_api(): - """Set up WS API handlers.""" - hacs = get_hacs() - hacs.log.info("Setup task %s", HacsSetupTask.WEBSOCKET) - websocket_api.async_register_command(hacs.hass, hacs_settings) - websocket_api.async_register_command(hacs.hass, hacs_config) - websocket_api.async_register_command(hacs.hass, hacs_repositories) - websocket_api.async_register_command(hacs.hass, hacs_repository) - websocket_api.async_register_command(hacs.hass, hacs_repository_data) - websocket_api.async_register_command(hacs.hass, check_local_path) - websocket_api.async_register_command(hacs.hass, hacs_status) - websocket_api.async_register_command(hacs.hass, hacs_removed) - websocket_api.async_register_command(hacs.hass, acknowledge_critical_repository) - websocket_api.async_register_command(hacs.hass, get_critical_repositories) diff --git a/custom_components/hacs/sensor.py b/custom_components/hacs/sensor.py index 22bb34b1358..f2177e89d28 100644 --- a/custom_components/hacs/sensor.py +++ b/custom_components/hacs/sensor.py @@ -2,7 +2,7 @@ from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from custom_components.hacs.const import DOMAIN, INTEGRATION_VERSION, NAME_SHORT +from custom_components.hacs.const import DOMAIN, NAME_SHORT from custom_components.hacs.mixin import HacsMixin @@ -29,7 +29,7 @@ def device_info(self): "name": NAME_SHORT, "manufacturer": "hacs.xyz", "model": "", - "sw_version": INTEGRATION_VERSION, + "sw_version": self.hacs.version, "entry_type": "service", } diff --git a/custom_components/hacs/tasks/__init__.py b/custom_components/hacs/tasks/__init__.py new file mode 100644 index 00000000000..db567c4a005 --- /dev/null +++ b/custom_components/hacs/tasks/__init__.py @@ -0,0 +1 @@ +"""Init HACS tasks.""" diff --git a/custom_components/hacs/tasks/base.py b/custom_components/hacs/tasks/base.py new file mode 100644 index 00000000000..04ac048845b --- /dev/null +++ b/custom_components/hacs/tasks/base.py @@ -0,0 +1,86 @@ +""""Hacs base setup task.""" +# pylint: disable=abstract-method +from __future__ import annotations + +from abc import abstractmethod +from datetime import timedelta +from timeit import default_timer as timer + +from homeassistant.core import HomeAssistant + +from ..enums import HacsStage, HacsTaskType +from ..mixin import HacsMixin, LogMixin + + +class HacsTaskBase(HacsMixin, LogMixin): + """"Hacs task base.""" + + hass: HomeAssistant + + type = HacsTaskType.BASE + + def __init__(self) -> None: + self.hass = self.hacs.hass + + @property + def slug(self) -> str: + """Return the check slug.""" + return self.__class__.__module__.rsplit(".", maxsplit=1)[-1] + + @abstractmethod + async def execute(self) -> None: + """Execute the task.""" + raise NotImplementedError + + async def execute_task(self) -> None: + """This should only be executed by the manager.""" + if self.hacs.system.disabled: + self.log.warning( + "Skipping task %s, HACS is disabled - %s", + self.slug, + self.hacs.system.disabled_reason, + ) + return + self.log.info("Executing task: %s", self.slug) + start_time = timer() + await self.execute() + self.log.debug( + "Task %s took " "%.2f seconds to complete", self.slug, timer() - start_time + ) + + +class HacsTaskEventBase(HacsTaskBase): + """"HacsTaskEventBase.""" + + type = HacsTaskType.EVENT + + @property + @abstractmethod + def event(self) -> str: + """Return the event to listen to.""" + raise NotImplementedError + + +class HacsTaskScheduleBase(HacsTaskBase): + """"HacsTaskScheduleBase.""" + + type = HacsTaskType.SCHEDULE + + @property + @abstractmethod + def schedule(self) -> timedelta: + """Return the schedule.""" + raise NotImplementedError + + +class HacsTaskManualBase(HacsTaskBase): + """"HacsTaskManualBase.""" + + type = HacsTaskType.MANUAL + + +class HacsTaskRuntimeBase(HacsTaskBase): + """"HacsTaskRuntimeBase.""" + + type = HacsTaskType.RUNTIME + stages = list(HacsStage) diff --git a/custom_components/hacs/tasks/hello_world.py b/custom_components/hacs/tasks/hello_world.py new file mode 100644 index 00000000000..b49163fb21d --- /dev/null +++ b/custom_components/hacs/tasks/hello_world.py @@ -0,0 +1,14 @@ +""""Hacs base setup task.""" +from .base import HacsTaskManualBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskManualBase): + """"Hacs task base.""" + + async def execute(self) -> None: + self.log.debug("Hello World!") diff --git a/custom_components/hacs/tasks/manager.py b/custom_components/hacs/tasks/manager.py new file mode 100644 index 00000000000..5c99ae703f2 --- /dev/null +++ b/custom_components/hacs/tasks/manager.py @@ -0,0 +1,54 @@ +"""Hacs task manager.""" +from __future__ import annotations + +import asyncio +from importlib import import_module +from pathlib import Path + +from ..enums import HacsTaskType +from ..mixin import HacsMixin, LogMixin +from .base import HacsTaskBase + + +class HacsTaskManager(HacsMixin, LogMixin): + """Hacs task manager.""" + + def __init__(self) -> None: + """Initialize the setup manager class.""" + self.__tasks: dict[str, HacsTaskBase] = {} + + @property + def tasks(self) -> list[HacsTaskBase]: + """Return all list of all tasks.""" + return list(self.__tasks.values()) + + async def async_load(self) -> None: + """Load all tasks.""" + task_files = Path(__file__).parent + task_modules = ( + module.stem + for module in task_files.glob("*.py") + if module.name not in ("base.py", "__init__.py", "manager.py") + ) + + async def _load_module(module: str): + task_module = import_module(f"{__package__}.{module}") + if task := await task_module.async_setup(): + self.__tasks[task.slug] = task + + await asyncio.gather(*[_load_module(task) for task in task_modules]) + self.log.info("Loaded %s tasks", len(self.tasks)) + + def get(self, slug: str) -> HacsTaskBase | None: + """Return a task.""" + return self.__tasks.get(slug) + + async def async_execute_runtume_tasks(self) -> None: + """Execute the the execute methods of each runtime task if the stage matches.""" + await asyncio.gather( + *( + task.execute_task() + for task in self.tasks + if task.type == HacsTaskType.RUNTIME and self.hacs.stage in task.stages + ) + ) diff --git a/custom_components/hacs/tasks/setup_categories.py b/custom_components/hacs/tasks/setup_categories.py new file mode 100644 index 00000000000..1d2074362c1 --- /dev/null +++ b/custom_components/hacs/tasks/setup_categories.py @@ -0,0 +1,30 @@ +"""Starting setup task: extra stores.""" +from ..enums import HacsCategory, HacsStage +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Set up extra stores in HACS if enabled in Home Assistant.""" + + stages = [HacsStage.SETUP, HacsStage.RUNNING] + + async def execute(self) -> None: + self.hacs.common.categories = set() + for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN): + self.hacs.enable_hacs_category(HacsCategory(category)) + + if HacsCategory.PYTHON_SCRIPT in self.hacs.hass.config.components: + self.hacs.enable_hacs_category(HacsCategory.PYTHON_SCRIPT) + + if self.hacs.hass.services.has_service("frontend", "reload_themes"): + self.hacs.enable_hacs_category(HacsCategory.THEME) + + if self.hacs.configuration.appdaemon: + self.hacs.enable_hacs_category(HacsCategory.APPDAEMON) + if self.hacs.configuration.netdaemon: + self.hacs.enable_hacs_category(HacsCategory.NETDAEMON) diff --git a/custom_components/hacs/tasks/setup_clear_old_storage.py b/custom_components/hacs/tasks/setup_clear_old_storage.py new file mode 100644 index 00000000000..6a088eb838b --- /dev/null +++ b/custom_components/hacs/tasks/setup_clear_old_storage.py @@ -0,0 +1,27 @@ +"""Starting setup task: clear storage.""" +import os + +from ..enums import HacsStage +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Clear old files from storage.""" + + stages = [HacsStage.SETUP] + + async def execute(self) -> None: + await self.hacs.hass.async_add_executor_job(self._clear_storage) + + def _clear_storage(self) -> None: + """Clear old files from storage.""" + for storage_file in ("hacs",): + path = f"{self.hacs.core.config_path}/.storage/{storage_file}" + if os.path.isfile(path): + self.log.info("Cleaning up old storage file: %s", path) + os.remove(path) diff --git a/custom_components/hacs/tasks/setup_constrains.py b/custom_components/hacs/tasks/setup_constrains.py new file mode 100644 index 00000000000..c749cdf8a74 --- /dev/null +++ b/custom_components/hacs/tasks/setup_constrains.py @@ -0,0 +1,50 @@ +""""Starting setup task: Constrains".""" +from ..utils.version import version_left_higher_then_right +from ..const import MINIMUM_HA_VERSION +import os +from ..enums import HacsDisabledReason, HacsStage +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Check env Constrains.""" + + stages = [HacsStage.SETUP] + + async def execute(self) -> None: + if not await self.hass.async_add_executor_job(self.constrain_custom_updater): + self.hacs.disable_hacs(HacsDisabledReason.CONSTRAINS) + if not await self.hass.async_add_executor_job(self.constrain_version): + self.hacs.disable_hacs(HacsDisabledReason.CONSTRAINS) + + def constrain_custom_updater(self) -> None: + """Check if custom_updater exist.""" + for location in ( + self.hass.config.path("custom_components/custom_updater.py"), + self.hass.config.path("custom_components/custom_updater.py"), + ): + if os.path.exists(location): + self.log.critical( + "This cannot be used with custom_updater. " + "To use this you need to remove custom_updater form %s", + location, + ) + return False + return True + + def constrain_version(self) -> None: + """Check if the version is valid.""" + if not version_left_higher_then_right( + self.hacs.core.ha_version, MINIMUM_HA_VERSION + ): + self.log.critical( + "You need HA version %s or newer to use this integration.", + MINIMUM_HA_VERSION, + ) + return False + return True diff --git a/custom_components/hacs/tasks/setup_frontend.py b/custom_components/hacs/tasks/setup_frontend.py new file mode 100644 index 00000000000..126d4c9cea5 --- /dev/null +++ b/custom_components/hacs/tasks/setup_frontend.py @@ -0,0 +1,85 @@ +""""Starting setup task: Frontend".""" + +from hacs_frontend import locate_dir +from hacs_frontend.version import VERSION as FE_VERSION + +from ..const import DOMAIN +from ..enums import HacsStage +from ..webresponses.frontend import HacsFrontendDev +from .base import HacsTaskRuntimeBase + +URL_BASE = "/hacsfiles" + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Setup the HACS frontend.""" + + stages = [HacsStage.SETUP] + + async def execute(self) -> None: + + # Register themes + self.hass.http.register_static_path( + f"{URL_BASE}/themes", self.hass.config.path("themes") + ) + + # Register frontend + if self.hacs.configuration.frontend_repo_url: + self.log.warning( + "Frontend development mode enabled. Do not run in production!" + ) + self.hass.http.register_view(HacsFrontendDev()) + else: + # + self.hass.http.register_static_path( + f"{URL_BASE}/frontend", locate_dir(), cache_headers=False + ) + + # Custom iconset + self.hass.http.register_static_path( + f"{URL_BASE}/iconset.js", str(self.hacs.integration_dir / "iconset.js") + ) + if "frontend_extra_module_url" not in self.hass.data: + self.hass.data["frontend_extra_module_url"] = set() + self.hass.data["frontend_extra_module_url"].add("/hacsfiles/iconset.js") + + # Register www/community for all other files + use_cache = self.hacs.core.lovelace_mode == "storage" + self.log.info( + "%s mode, cache for /hacsfiles/: %s", + self.hacs.core.lovelace_mode, + use_cache, + ) + self.hass.http.register_static_path( + URL_BASE, + self.hass.config.path("www/community"), + cache_headers=use_cache, + ) + + self.hacs.frontend.version_running = FE_VERSION + for requirement in self.hacs.integration.requirements: + if "hacs_frontend" in requirement: + self.hacs.frontend.version_expected = requirement.split("==")[-1] + + # Add to sidepanel if needed + if DOMAIN not in self.hass.data.get("frontend_panels", {}): + self.hass.components.frontend.async_register_built_in_panel( + component_name="custom", + sidebar_title=self.hacs.configuration.sidepanel_title, + sidebar_icon=self.hacs.configuration.sidepanel_icon, + frontend_url_path=DOMAIN, + config={ + "_panel_custom": { + "name": "hacs-frontend", + "embed_iframe": True, + "trust_external": False, + "js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={FE_VERSION}", + } + }, + require_admin=True, + ) diff --git a/custom_components/hacs/tasks/setup_restore.py b/custom_components/hacs/tasks/setup_restore.py new file mode 100644 index 00000000000..33ec29daaec --- /dev/null +++ b/custom_components/hacs/tasks/setup_restore.py @@ -0,0 +1,18 @@ +""""Starting setup task: Restore".""" +from ..enums import HacsDisabledReason, HacsStage +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Restore HACS data.""" + + stages = [HacsStage.SETUP] + + async def execute(self) -> None: + if not await self.hacs.data.restore(): + self.hacs.disable_hacs(HacsDisabledReason.RESTORE) diff --git a/custom_components/hacs/tasks/setup_sensor.py b/custom_components/hacs/tasks/setup_sensor.py new file mode 100644 index 00000000000..df4728f447d --- /dev/null +++ b/custom_components/hacs/tasks/setup_sensor.py @@ -0,0 +1,32 @@ +""""Starting setup task: Sensor".""" + +from homeassistant.helpers.discovery import async_load_platform + +from ..const import DOMAIN +from ..enums import ConfigurationType, HacsStage +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Setup the HACS sensor platform.""" + + stages = [HacsStage.SETUP] + + async def execute(self) -> None: + if self.hacs.configuration.config_type == ConfigurationType.YAML: + self.hass.async_create_task( + async_load_platform( + self.hass, "sensor", DOMAIN, {}, self.hacs.configuration.config + ) + ) + else: + self.hass.async_add_job( + self.hass.config_entries.async_forward_entry_setup( + self.hacs.configuration.config_entry, "sensor" + ) + ) diff --git a/custom_components/hacs/tasks/setup_verify_api.py b/custom_components/hacs/tasks/setup_verify_api.py new file mode 100644 index 00000000000..e606a358c27 --- /dev/null +++ b/custom_components/hacs/tasks/setup_verify_api.py @@ -0,0 +1,18 @@ +""""Starting setup task: Verify API".""" +from ..enums import HacsStage +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Verify the connection to the GitHub API.""" + + stages = [HacsStage.SETUP] + + async def execute(self) -> None: + can_update = await self.hacs.async_can_update() + self.log.debug("Can update %s repositories", can_update) diff --git a/custom_components/hacs/tasks/setup_websocket_api.py b/custom_components/hacs/tasks/setup_websocket_api.py new file mode 100644 index 00000000000..6ce207ec240 --- /dev/null +++ b/custom_components/hacs/tasks/setup_websocket_api.py @@ -0,0 +1,38 @@ +"""Register WS API endpoints for HACS.""" +from homeassistant.components.websocket_api import async_register_command + +from ..api.acknowledge_critical_repository import acknowledge_critical_repository +from ..api.check_local_path import check_local_path +from ..api.get_critical_repositories import get_critical_repositories +from ..api.hacs_config import hacs_config +from ..api.hacs_removed import hacs_removed +from ..api.hacs_repositories import hacs_repositories +from ..api.hacs_repository import hacs_repository +from ..api.hacs_repository_data import hacs_repository_data +from ..api.hacs_settings import hacs_settings +from ..api.hacs_status import hacs_status +from ..enums import HacsStage +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Setup the HACS websocket API.""" + + stages = [HacsStage.SETUP] + + async def execute(self) -> None: + async_register_command(self.hass, hacs_settings) + async_register_command(self.hass, hacs_config) + async_register_command(self.hass, hacs_repositories) + async_register_command(self.hass, hacs_repository) + async_register_command(self.hass, hacs_repository_data) + async_register_command(self.hass, check_local_path) + async_register_command(self.hass, hacs_status) + async_register_command(self.hass, hacs_removed) + async_register_command(self.hass, acknowledge_critical_repository) + async_register_command(self.hass, get_critical_repositories) diff --git a/custom_components/hacs/tasks/startup_load_hacs_repository.py b/custom_components/hacs/tasks/startup_load_hacs_repository.py new file mode 100644 index 00000000000..8781d61b63b --- /dev/null +++ b/custom_components/hacs/tasks/startup_load_hacs_repository.py @@ -0,0 +1,39 @@ +"""Starting setup task: load HACS repository.""" +from ..enums import HacsDisabledReason, HacsStage +from ..exceptions import HacsException +from ..helpers.functions.information import get_repository +from ..helpers.functions.register_repository import register_repository +from .base import HacsTaskRuntimeBase + + +async def async_setup() -> None: + """Set up this task.""" + return Task() + + +class Task(HacsTaskRuntimeBase): + """Load HACS repositroy.""" + + stages = [HacsStage.STARTUP] + + async def execute(self) -> None: + try: + repository = self.hacs.get_by_name("hacs/integration") + if repository is None: + await register_repository("hacs/integration", "integration") + repository = self.hacs.get_by_name("hacs/integration") + if repository is None: + raise HacsException("Unknown error") + repository.data.installed = True + repository.data.installed_version = self.hacs.integration.version + repository.data.new = False + self.hacs.repository = repository.repository_object + self.hacs.data_repo, _ = await get_repository( + self.hacs.session, self.hacs.configuration.token, "hacs/default", None + ) + except HacsException as exception: + if "403" in f"{exception}": + self.log.critical("GitHub API is ratelimited, or the token is wrong.") + else: + self.log.critical("[%s] - Could not load HACS!", exception) + self.hacs.disable_hacs(HacsDisabledReason.LOAD_HACS) diff --git a/custom_components/hacs/utils/version.py b/custom_components/hacs/utils/version.py new file mode 100644 index 00000000000..f7dc62303bc --- /dev/null +++ b/custom_components/hacs/utils/version.py @@ -0,0 +1,11 @@ +"""Version utils.""" + + +from functools import lru_cache +from awesomeversion import AwesomeVersion + + +@lru_cache(maxsize=1024) +def version_left_higher_then_right(left: str, right: str) -> bool: + """Return a bool if source is newer than target, will also be true if identical.""" + return AwesomeVersion(left) >= AwesomeVersion(right) diff --git a/tests/conftest.py b/tests/conftest.py index ce800e84b58..d96b3ce68c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,16 @@ """Set up some common test helper things.""" import asyncio import logging +from pathlib import Path +from unittest.mock import AsyncMock import pytest from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.loader import Integration from homeassistant.runner import HassEventLoopPolicy +from custom_components.hacs.const import DOMAIN from custom_components.hacs.hacsbase.hacs import Hacs from custom_components.hacs.helpers.classes.repository import HacsRepository from custom_components.hacs.helpers.functions.version_to_install import ( @@ -21,6 +25,7 @@ HacsThemeRepository, ) from custom_components.hacs.share import SHARE +from custom_components.hacs.tasks.manager import HacsTaskManager from tests.async_mock import MagicMock from tests.common import ( # noqa: E402, isort:skip @@ -88,7 +93,15 @@ def hacs(hass): """Fixture to provide a HACS object.""" hacs_obj = Hacs() hacs_obj.hass = hass + hacs_obj.tasks = AsyncMock() hacs_obj.session = async_create_clientsession(hass) + hacs_obj.integration = Integration( + hass=hass, + pkg_path="custom_components.hacs", + file_path=Path(hass.config.path("custom_components/hacs")), + manifest={"domain": DOMAIN, "version": "0.0.0"}, + ) + hacs_obj.version = hacs_obj.integration.version hacs_obj.configuration.token = TOKEN hacs_obj.core.config_path = hass.config.path() hacs_obj.system.action = False diff --git a/tests/hacsbase/test_hacs.py b/tests/hacsbase/test_hacs.py index 56ed376c451..ceb5d7d064c 100644 --- a/tests/hacsbase/test_hacs.py +++ b/tests/hacsbase/test_hacs.py @@ -60,6 +60,6 @@ async def test_add_remove_repository(hacs, repository, tmpdir): @pytest.mark.asyncio async def test_set_stage(hacs): - assert hacs.stage == HacsStage.SETUP + assert hacs.stage == None await hacs.async_set_stage(HacsStage.RUNNING) assert hacs.stage == HacsStage.RUNNING diff --git a/tests/helpers/misc/test_get_repository_name.py b/tests/helpers/misc/test_get_repository_name.py index 2a3261a16d8..5fd44e5796d 100644 --- a/tests/helpers/misc/test_get_repository_name.py +++ b/tests/helpers/misc/test_get_repository_name.py @@ -1,11 +1,15 @@ """Helpers: Misc: get_repository_name.""" -from custom_components.hacs.const import ELEMENT_TYPES +from custom_components.hacs.enums import HacsCategory from custom_components.hacs.helpers.classes.manifest import HacsManifest # pylint: disable=missing-docstring from custom_components.hacs.helpers.functions.misc import get_repository_name -ELEMENT_TYPES = ELEMENT_TYPES + ["appdaemon", "python_script", "theme"] +ELEMENT_TYPES = ( + HacsCategory.INTEGRATION, + HacsCategory.LOVELACE, + HacsCategory.PLUGIN, +) + (HacsCategory.APPDAEMON, HacsCategory.PYTHON_SCRIPT, HacsCategory.THEME) def test_everything(repository): diff --git a/tests/operational/setup_actions/test_categories_setup.py b/tests/operational/setup_actions/test_categories_setup.py deleted file mode 100644 index 542a30a77be..00000000000 --- a/tests/operational/setup_actions/test_categories_setup.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest - -from custom_components.hacs.enums import HacsCategory -from custom_components.hacs.operational.setup_actions.categories import ( - async_setup_extra_stores, -) - - -@pytest.mark.asyncio -async def test_extra_stores_python_script(hacs): - await async_setup_extra_stores() - assert HacsCategory.PYTHON_SCRIPT not in hacs.common.categories - hacs.hass.config.components.add("python_script") - await async_setup_extra_stores() - assert HacsCategory.PYTHON_SCRIPT in hacs.common.categories - - -@pytest.mark.asyncio -async def test_extra_stores_theme(hacs): - await async_setup_extra_stores() - assert HacsCategory.THEME not in hacs.common.categories - hacs.hass.services._services["frontend"] = {"reload_themes": "dummy"} - await async_setup_extra_stores() - assert HacsCategory.THEME in hacs.common.categories - - -@pytest.mark.asyncio -async def test_extra_stores_appdaemon(hacs): - await async_setup_extra_stores() - assert HacsCategory.APPDAEMON not in hacs.common.categories - hacs.configuration.appdaemon = True - await async_setup_extra_stores() - assert HacsCategory.APPDAEMON in hacs.common.categories - - -@pytest.mark.asyncio -async def test_extra_stores_netdaemon(hacs): - await async_setup_extra_stores() - assert HacsCategory.NETDAEMON not in hacs.common.categories - hacs.configuration.netdaemon = True - await async_setup_extra_stores() - assert HacsCategory.NETDAEMON in hacs.common.categories diff --git a/tests/operational/setup_actions/test_frontend_setup.py b/tests/operational/setup_actions/test_frontend_setup.py deleted file mode 100644 index bbff16b5754..00000000000 --- a/tests/operational/setup_actions/test_frontend_setup.py +++ /dev/null @@ -1,23 +0,0 @@ -import json -import os - -import pytest - -from custom_components.hacs.operational.setup_actions.frontend import ( - async_setup_frontend, -) - - -@pytest.mark.asyncio -async def test_frontend_setup(hacs, tmpdir): - hacs.core.config_path = tmpdir - - content = {} - - os.makedirs(f"{hacs.core.config_path}/custom_components/hacs", exist_ok=True) - - with open( - f"{hacs.core.config_path}/custom_components/hacs/manifest.json", "w" - ) as manifest: - manifest.write(json.dumps(content)) - await async_setup_frontend() diff --git a/tests/operational/setup_actions/test_sensor_setup.py b/tests/operational/setup_actions/test_sensor_setup.py deleted file mode 100644 index 9059c559c38..00000000000 --- a/tests/operational/setup_actions/test_sensor_setup.py +++ /dev/null @@ -1,29 +0,0 @@ -from custom_components.hacs.enums import ConfigurationType -import pytest -from homeassistant.config_entries import ConfigEntries, ConfigEntry - -from custom_components.hacs.operational.setup_actions.sensor import async_add_sensor - - -@pytest.mark.asyncio -async def test_async_add_sensor_ui(hacs, hass): - hass.data["custom_components"] = None - hass.config_entries = ConfigEntries(hass, {"hacs": {}}) - hacs.configuration.config_entry = ConfigEntry( - 1, - "hacs", - "hacs", - {}, - "user", - {}, - ) - hacs.configuration.config = {"key": "value"} - await async_add_sensor() - - -@pytest.mark.asyncio -async def test_async_add_sensor_yaml(hacs): - hacs.configuration.config = {"key": "value"} - - hacs.configuration.config_type = ConfigurationType.YAML - await async_add_sensor() diff --git a/tests/operational/setup_actions/test_websocket_setup.py b/tests/operational/setup_actions/test_websocket_setup.py deleted file mode 100644 index f2a5c737a13..00000000000 --- a/tests/operational/setup_actions/test_websocket_setup.py +++ /dev/null @@ -1,10 +0,0 @@ -import pytest - -from custom_components.hacs.operational.setup_actions.websocket_api import ( - async_setup_hacs_websockt_api, -) - - -@pytest.mark.asyncio -async def test_async_setup_hacs_websockt_api(): - await async_setup_hacs_websockt_api() diff --git a/tests/test_constrains.py b/tests/test_constrains.py deleted file mode 100644 index 0705b3c9c1c..00000000000 --- a/tests/test_constrains.py +++ /dev/null @@ -1,75 +0,0 @@ -"""HACS Constrains Test Suite.""" -# pylint: disable=missing-docstring,invalid-name -import os - -from custom_components.hacs.helpers.functions.constrains import ( - check_constrains, - constrain_custom_updater, - constrain_version, -) - -HAVERSION = "9999.99.9" - - -def temp_cleanup(tmpdir): - manifest = f"{tmpdir.dirname}/custom_components/hacs/manifest.json" - custom_updater1 = f"{tmpdir.dirname}/custom_components/custom_updater/__init__.py" - custom_updater2 = f"{tmpdir.dirname}/custom_components/custom_updater.py" - - if os.path.exists(manifest): - os.remove(manifest) - if os.path.exists(custom_updater1): - os.remove(custom_updater1) - if os.path.exists(custom_updater2): - os.remove(custom_updater2) - - -def test_check_constrains(hacs, tmpdir): - hacs.core.config_path = tmpdir.dirname - hacs.core.ha_version = HAVERSION - - assert check_constrains() - - hacs.core.ha_version = "0.97.0" - assert not check_constrains() - - hacs.core.ha_version = HAVERSION - - assert constrain_version() - assert check_constrains() - - temp_cleanup(tmpdir) - - -def test_ha_version(hacs, tmpdir): - hacs.core.config_path = tmpdir.dirname - hacs.core.ha_version = HAVERSION - assert constrain_version() - - hacs.core.ha_version = "9999.0.0" - assert constrain_version() - - hacs.core.ha_version = "0.97.0" - assert not constrain_version() - - temp_cleanup(tmpdir) - - -def test_custom_updater(hacs, tmpdir): - hacs.core.config_path = tmpdir.dirname - - assert constrain_custom_updater() - - custom_updater_dir = f"{hacs.core.config_path}/custom_components/custom_updater" - os.makedirs(custom_updater_dir, exist_ok=True) - with open(f"{custom_updater_dir}/__init__.py", "w") as cufile: - cufile.write("") - assert not constrain_custom_updater() - - custom_updater_dir = f"{hacs.core.config_path}/custom_components" - os.makedirs(custom_updater_dir, exist_ok=True) - with open(f"{custom_updater_dir}/custom_updater.py", "w") as cufile: - cufile.write("") - assert not constrain_custom_updater() - - temp_cleanup(tmpdir) diff --git a/tests/test_setup.py b/tests/test_setup.py deleted file mode 100644 index 98b9fa5b595..00000000000 --- a/tests/test_setup.py +++ /dev/null @@ -1,26 +0,0 @@ -"""HACS Setup Test Suite.""" -# pylint: disable=missing-docstring -import os - -import pytest - -from custom_components.hacs.operational.setup_actions.clear_storage import ( - async_clear_storage, -) - - -@pytest.mark.asyncio -async def test_clear_storage(hacs): - os.makedirs(f"{hacs.core.config_path}/.storage") - with open(f"{hacs.core.config_path}/.storage/hacs", "w") as h_f: - h_f.write("") - assert os.path.exists(f"{hacs.core.config_path}/.storage/hacs") - - await async_clear_storage() - assert not os.path.exists(f"{hacs.core.config_path}/.storage/hacs") - - os.makedirs(f"{hacs.core.config_path}/.storage/hacs") - assert os.path.exists(f"{hacs.core.config_path}/.storage/hacs") - - await async_clear_storage() - assert os.path.exists(f"{hacs.core.config_path}/.storage/hacs")