From 80d8215d5caefb7b1b7ed51cd8edee43abf59b99 Mon Sep 17 00:00:00 2001 From: Correl Roush Date: Mon, 1 Feb 2021 23:58:39 -0500 Subject: [PATCH] Add HACS --- .gitignore | 1 + custom_components/hacs/__init__.py | 26 ++ custom_components/hacs/api/__init__.py | 1 + .../api/acknowledge_critical_repository.py | 25 + .../hacs/api/check_local_path.py | 24 + .../hacs/api/get_critical_repositories.py | 15 + custom_components/hacs/api/hacs_config.py | 28 ++ custom_components/hacs/api/hacs_removed.py | 15 + .../hacs/api/hacs_repositories.py | 62 +++ custom_components/hacs/api/hacs_repository.py | 113 +++++ .../hacs/api/hacs_repository_data.py | 121 +++++ custom_components/hacs/api/hacs_settings.py | 54 +++ custom_components/hacs/api/hacs_status.py | 23 + custom_components/hacs/base.py | 114 +++++ custom_components/hacs/config_flow.py | 153 ++++++ custom_components/hacs/const.py | 301 ++++++++++++ custom_components/hacs/enums.py | 39 ++ custom_components/hacs/hacsbase/__init__.py | 0 .../hacs/hacsbase/configuration.py | 77 ++++ custom_components/hacs/hacsbase/data.py | 204 ++++++++ custom_components/hacs/hacsbase/hacs.py | 360 +++++++++++++++ custom_components/hacs/helpers/__init__.py | 17 + .../hacs/helpers/classes/__init__.py | 0 .../hacs/helpers/classes/exceptions.py | 13 + .../hacs/helpers/classes/manifest.py | 43 ++ .../hacs/helpers/classes/removed.py | 21 + .../hacs/helpers/classes/repository.py | 435 ++++++++++++++++++ .../hacs/helpers/classes/repositorydata.py | 128 ++++++ .../hacs/helpers/classes/validate.py | 11 + .../hacs/helpers/functions/__init__.py | 0 .../helpers/functions/configuration_schema.py | 74 +++ .../hacs/helpers/functions/constrains.py | 43 ++ .../hacs/helpers/functions/download.py | 246 ++++++++++ .../hacs/helpers/functions/filters.py | 55 +++ .../functions/get_list_from_default.py | 35 ++ .../hacs/helpers/functions/information.py | 225 +++++++++ .../helpers/functions/is_safe_to_remove.py | 20 + .../hacs/helpers/functions/logger.py | 19 + .../hacs/helpers/functions/misc.py | 42 ++ .../hacs/helpers/functions/path_exsist.py | 13 + .../helpers/functions/register_repository.py | 70 +++ .../functions/remaining_github_calls.py | 32 ++ .../hacs/helpers/functions/save.py | 52 +++ .../hacs/helpers/functions/store.py | 34 ++ .../hacs/helpers/functions/template.py | 32 ++ .../helpers/functions/validate_repository.py | 101 ++++ .../helpers/functions/version_to_install.py | 20 + .../hacs/helpers/methods/__init__.py | 30 ++ .../hacs/helpers/methods/installation.py | 117 +++++ .../hacs/helpers/methods/registration.py | 43 ++ .../helpers/methods/reinstall_if_needed.py | 12 + .../hacs/helpers/properties/__init__.py | 16 + .../helpers/properties/can_be_installed.py | 21 + .../hacs/helpers/properties/custom.py | 13 + .../hacs/helpers/properties/pending_update.py | 23 + custom_components/hacs/iconset.js | 7 + custom_components/hacs/manifest.json | 25 + custom_components/hacs/models/__init__.py | 1 + custom_components/hacs/models/core.py | 15 + custom_components/hacs/models/frontend.py | 10 + custom_components/hacs/models/system.py | 16 + .../hacs/operational/__init__.py | 0 custom_components/hacs/operational/backup.py | 124 +++++ custom_components/hacs/operational/factory.py | 56 +++ custom_components/hacs/operational/reload.py | 10 + custom_components/hacs/operational/remove.py | 24 + custom_components/hacs/operational/runtime.py | 1 + custom_components/hacs/operational/setup.py | 198 ++++++++ .../operational/setup_actions/__init__.py | 0 .../operational/setup_actions/categories.py | 43 ++ .../setup_actions/clear_storage.py | 24 + .../operational/setup_actions/frontend.py | 110 +++++ .../setup_actions/load_hacs_repository.py | 38 ++ .../hacs/operational/setup_actions/sensor.py | 25 + .../setup_actions/websocket_api.py | 36 ++ .../hacs/repositories/__init__.py | 16 + .../hacs/repositories/appdaemon.py | 72 +++ .../hacs/repositories/integration.py | 97 ++++ .../hacs/repositories/netdaemon.py | 87 ++++ custom_components/hacs/repositories/plugin.py | 77 ++++ .../hacs/repositories/python_script.py | 83 ++++ custom_components/hacs/repositories/theme.py | 76 +++ custom_components/hacs/sensor.py | 123 +++++ custom_components/hacs/share.py | 68 +++ custom_components/hacs/system_health.py | 37 ++ custom_components/hacs/translations/en.json | 48 ++ custom_components/hacs/validate/README.md | 38 ++ custom_components/hacs/validate/__init__.py | 51 ++ custom_components/hacs/validate/base.py | 48 ++ .../hacs/validate/common/hacs_manifest.py | 10 + .../validate/common/repository_description.py | 10 + .../common/repository_information_file.py | 19 + .../hacs/validate/common/repository_topics.py | 10 + .../integration/integration_manifest.py | 10 + .../hacs/webresponses/__init__.py | 1 + .../hacs/webresponses/frontend.py | 26 ++ 96 files changed, 5582 insertions(+) create mode 100644 custom_components/hacs/__init__.py create mode 100644 custom_components/hacs/api/__init__.py create mode 100644 custom_components/hacs/api/acknowledge_critical_repository.py create mode 100644 custom_components/hacs/api/check_local_path.py create mode 100644 custom_components/hacs/api/get_critical_repositories.py create mode 100644 custom_components/hacs/api/hacs_config.py create mode 100644 custom_components/hacs/api/hacs_removed.py create mode 100644 custom_components/hacs/api/hacs_repositories.py create mode 100644 custom_components/hacs/api/hacs_repository.py create mode 100644 custom_components/hacs/api/hacs_repository_data.py create mode 100644 custom_components/hacs/api/hacs_settings.py create mode 100644 custom_components/hacs/api/hacs_status.py create mode 100644 custom_components/hacs/base.py create mode 100644 custom_components/hacs/config_flow.py create mode 100644 custom_components/hacs/const.py create mode 100644 custom_components/hacs/enums.py create mode 100644 custom_components/hacs/hacsbase/__init__.py create mode 100644 custom_components/hacs/hacsbase/configuration.py create mode 100644 custom_components/hacs/hacsbase/data.py create mode 100644 custom_components/hacs/hacsbase/hacs.py create mode 100644 custom_components/hacs/helpers/__init__.py create mode 100644 custom_components/hacs/helpers/classes/__init__.py create mode 100644 custom_components/hacs/helpers/classes/exceptions.py create mode 100644 custom_components/hacs/helpers/classes/manifest.py create mode 100644 custom_components/hacs/helpers/classes/removed.py create mode 100644 custom_components/hacs/helpers/classes/repository.py create mode 100644 custom_components/hacs/helpers/classes/repositorydata.py create mode 100644 custom_components/hacs/helpers/classes/validate.py create mode 100644 custom_components/hacs/helpers/functions/__init__.py create mode 100644 custom_components/hacs/helpers/functions/configuration_schema.py create mode 100644 custom_components/hacs/helpers/functions/constrains.py create mode 100644 custom_components/hacs/helpers/functions/download.py create mode 100644 custom_components/hacs/helpers/functions/filters.py create mode 100644 custom_components/hacs/helpers/functions/get_list_from_default.py create mode 100644 custom_components/hacs/helpers/functions/information.py create mode 100644 custom_components/hacs/helpers/functions/is_safe_to_remove.py create mode 100644 custom_components/hacs/helpers/functions/logger.py create mode 100644 custom_components/hacs/helpers/functions/misc.py create mode 100644 custom_components/hacs/helpers/functions/path_exsist.py create mode 100644 custom_components/hacs/helpers/functions/register_repository.py create mode 100644 custom_components/hacs/helpers/functions/remaining_github_calls.py create mode 100644 custom_components/hacs/helpers/functions/save.py create mode 100644 custom_components/hacs/helpers/functions/store.py create mode 100644 custom_components/hacs/helpers/functions/template.py create mode 100644 custom_components/hacs/helpers/functions/validate_repository.py create mode 100644 custom_components/hacs/helpers/functions/version_to_install.py create mode 100644 custom_components/hacs/helpers/methods/__init__.py create mode 100644 custom_components/hacs/helpers/methods/installation.py create mode 100644 custom_components/hacs/helpers/methods/registration.py create mode 100644 custom_components/hacs/helpers/methods/reinstall_if_needed.py create mode 100644 custom_components/hacs/helpers/properties/__init__.py create mode 100644 custom_components/hacs/helpers/properties/can_be_installed.py create mode 100644 custom_components/hacs/helpers/properties/custom.py create mode 100644 custom_components/hacs/helpers/properties/pending_update.py create mode 100644 custom_components/hacs/iconset.js create mode 100644 custom_components/hacs/manifest.json create mode 100644 custom_components/hacs/models/__init__.py create mode 100644 custom_components/hacs/models/core.py create mode 100644 custom_components/hacs/models/frontend.py create mode 100644 custom_components/hacs/models/system.py create mode 100644 custom_components/hacs/operational/__init__.py create mode 100644 custom_components/hacs/operational/backup.py create mode 100644 custom_components/hacs/operational/factory.py create mode 100644 custom_components/hacs/operational/reload.py create mode 100644 custom_components/hacs/operational/remove.py create mode 100644 custom_components/hacs/operational/runtime.py create mode 100644 custom_components/hacs/operational/setup.py create mode 100644 custom_components/hacs/operational/setup_actions/__init__.py create mode 100644 custom_components/hacs/operational/setup_actions/categories.py create mode 100644 custom_components/hacs/operational/setup_actions/clear_storage.py create mode 100644 custom_components/hacs/operational/setup_actions/frontend.py create mode 100644 custom_components/hacs/operational/setup_actions/load_hacs_repository.py create mode 100644 custom_components/hacs/operational/setup_actions/sensor.py create mode 100644 custom_components/hacs/operational/setup_actions/websocket_api.py create mode 100644 custom_components/hacs/repositories/__init__.py create mode 100644 custom_components/hacs/repositories/appdaemon.py create mode 100644 custom_components/hacs/repositories/integration.py create mode 100644 custom_components/hacs/repositories/netdaemon.py create mode 100644 custom_components/hacs/repositories/plugin.py create mode 100644 custom_components/hacs/repositories/python_script.py create mode 100644 custom_components/hacs/repositories/theme.py create mode 100644 custom_components/hacs/sensor.py create mode 100644 custom_components/hacs/share.py create mode 100644 custom_components/hacs/system_health.py create mode 100644 custom_components/hacs/translations/en.json create mode 100644 custom_components/hacs/validate/README.md create mode 100644 custom_components/hacs/validate/__init__.py create mode 100644 custom_components/hacs/validate/base.py create mode 100644 custom_components/hacs/validate/common/hacs_manifest.py create mode 100644 custom_components/hacs/validate/common/repository_description.py create mode 100644 custom_components/hacs/validate/common/repository_information_file.py create mode 100644 custom_components/hacs/validate/common/repository_topics.py create mode 100644 custom_components/hacs/validate/integration/integration_manifest.py create mode 100644 custom_components/hacs/webresponses/__init__.py create mode 100644 custom_components/hacs/webresponses/frontend.py diff --git a/.gitignore b/.gitignore index 029e028..e4bf152 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ zones.yaml .storage/ tts/ +__pycache__/ *.conf *.db diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py new file mode 100644 index 0000000..cdec6cb --- /dev/null +++ b/custom_components/hacs/__init__.py @@ -0,0 +1,26 @@ +""" +HACS gives you a powerful UI to handle downloads of all your custom needs. + +For more details about this integration, please refer to the documentation at +https://hacs.xyz/ +""" +import voluptuous as vol + +from .const import DOMAIN +from .helpers.functions.configuration_schema import hacs_config_combined +from .operational.setup import async_setup as hacs_yaml_setup +from .operational.setup import async_setup_entry as hacs_ui_setup + +CONFIG_SCHEMA = vol.Schema({DOMAIN: hacs_config_combined()}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up this integration using yaml.""" + + return await hacs_yaml_setup(hass, config) + + +async def async_setup_entry(hass, config_entry): + """Set up this integration using UI.""" + + return await hacs_ui_setup(hass, config_entry) diff --git a/custom_components/hacs/api/__init__.py b/custom_components/hacs/api/__init__.py new file mode 100644 index 0000000..f8017b3 --- /dev/null +++ b/custom_components/hacs/api/__init__.py @@ -0,0 +1 @@ +"""Initialize HACS API""" diff --git a/custom_components/hacs/api/acknowledge_critical_repository.py b/custom_components/hacs/api/acknowledge_critical_repository.py new file mode 100644 index 0000000..02235b2 --- /dev/null +++ b/custom_components/hacs/api/acknowledge_critical_repository.py @@ -0,0 +1,25 @@ +"""API Handler for acknowledge_critical_repository""" +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.helpers.functions.store import ( + async_load_from_store, + async_save_to_store, +) + + +@websocket_api.async_response +@websocket_api.websocket_command( + {vol.Required("type"): "hacs/critical", vol.Optional("repository"): cv.string} +) +async def acknowledge_critical_repository(hass, connection, msg): + """Handle get media player cover command.""" + repository = msg["repository"] + + critical = await async_load_from_store(hass, "critical") + for repo in critical: + if repository == repo["repository"]: + repo["acknowledged"] = True + await async_save_to_store(hass, "critical", critical) + connection.send_message(websocket_api.result_message(msg["id"], critical)) diff --git a/custom_components/hacs/api/check_local_path.py b/custom_components/hacs/api/check_local_path.py new file mode 100644 index 0000000..308b81b --- /dev/null +++ b/custom_components/hacs/api/check_local_path.py @@ -0,0 +1,24 @@ +"""API Handler for check_local_path""" +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.helpers.functions.path_exsist import async_path_exsist + + +@websocket_api.async_response +@websocket_api.websocket_command( + {vol.Required("type"): "hacs/check_path", vol.Optional("path"): cv.string} +) +async def check_local_path(_hass, connection, msg): + """Handle get media player cover command.""" + path = msg.get("path") + exist = {"exist": False} + + if path is None: + return + + if await async_path_exsist(path): + exist["exist"] = True + + connection.send_message(websocket_api.result_message(msg["id"], exist)) diff --git a/custom_components/hacs/api/get_critical_repositories.py b/custom_components/hacs/api/get_critical_repositories.py new file mode 100644 index 0000000..35f2c94 --- /dev/null +++ b/custom_components/hacs/api/get_critical_repositories.py @@ -0,0 +1,15 @@ +"""API Handler for get_critical_repositories""" +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.helpers.functions.store import async_load_from_store + + +@websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "hacs/get_critical"}) +async def get_critical_repositories(hass, connection, msg): + """Handle get media player cover command.""" + critical = await async_load_from_store(hass, "critical") + if not critical: + critical = [] + connection.send_message(websocket_api.result_message(msg["id"], critical)) diff --git a/custom_components/hacs/api/hacs_config.py b/custom_components/hacs/api/hacs_config.py new file mode 100644 index 0000000..1760ee8 --- /dev/null +++ b/custom_components/hacs/api/hacs_config.py @@ -0,0 +1,28 @@ +"""API Handler for hacs_config""" +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.share import get_hacs + + +@websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "hacs/config"}) +async def hacs_config(_hass, connection, msg): + """Handle get media player cover command.""" + hacs = get_hacs() + config = hacs.configuration + + content = {} + content["frontend_mode"] = config.frontend_mode + content["frontend_compact"] = config.frontend_compact + content["onboarding_done"] = config.onboarding_done + content["version"] = hacs.version + content["frontend_expected"] = hacs.frontend.version_expected + content["frontend_running"] = hacs.frontend.version_running + content["dev"] = config.dev + content["debug"] = config.debug + content["country"] = config.country + content["experimental"] = config.experimental + content["categories"] = hacs.common.categories + + connection.send_message(websocket_api.result_message(msg["id"], content)) diff --git a/custom_components/hacs/api/hacs_removed.py b/custom_components/hacs/api/hacs_removed.py new file mode 100644 index 0000000..28628e8 --- /dev/null +++ b/custom_components/hacs/api/hacs_removed.py @@ -0,0 +1,15 @@ +"""API Handler for hacs_removed""" +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.share import list_removed_repositories + + +@websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "hacs/removed"}) +async def hacs_removed(_hass, connection, msg): + """Get information about removed repositories.""" + content = [] + for repo in list_removed_repositories(): + content.append(repo.to_json()) + connection.send_message(websocket_api.result_message(msg["id"], content)) diff --git a/custom_components/hacs/api/hacs_repositories.py b/custom_components/hacs/api/hacs_repositories.py new file mode 100644 index 0000000..e003342 --- /dev/null +++ b/custom_components/hacs/api/hacs_repositories.py @@ -0,0 +1,62 @@ +"""API Handler for hacs_repositories""" +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.share import get_hacs + + +@websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "hacs/repositories"}) +async def hacs_repositories(_hass, connection, msg): + """Handle get media player cover command.""" + hacs = get_hacs() + repositories = hacs.repositories + content = [] + for repo in repositories: + if repo.data.category in hacs.common.categories: + data = { + "additional_info": repo.information.additional_info, + "authors": repo.data.authors, + "available_version": repo.display_available_version, + "beta": repo.data.show_beta, + "can_install": repo.can_install, + "category": repo.data.category, + "country": repo.data.country, + "config_flow": repo.data.config_flow, + "custom": repo.custom, + "default_branch": repo.data.default_branch, + "description": repo.data.description, + "domain": repo.data.domain, + "downloads": repo.data.downloads, + "file_name": repo.data.file_name, + "first_install": repo.status.first_install, + "full_name": repo.data.full_name, + "hide": repo.data.hide, + "hide_default_branch": repo.data.hide_default_branch, + "homeassistant": repo.data.homeassistant, + "id": repo.data.id, + "info": repo.information.info, + "installed_version": repo.display_installed_version, + "installed": repo.data.installed, + "issues": repo.data.open_issues, + "javascript_type": repo.information.javascript_type, + "last_updated": repo.data.last_updated, + "local_path": repo.content.path.local, + "main_action": repo.main_action, + "name": repo.display_name, + "new": repo.data.new, + "pending_upgrade": repo.pending_upgrade, + "releases": repo.data.published_tags, + "selected_tag": repo.data.selected_tag, + "stars": repo.data.stargazers_count, + "state": repo.state, + "status_description": repo.display_status_description, + "status": repo.display_status, + "topics": repo.data.topics, + "updated_info": repo.status.updated_info, + "version_or_commit": repo.display_version_or_commit, + } + + content.append(data) + + connection.send_message(websocket_api.result_message(msg["id"], content)) diff --git a/custom_components/hacs/api/hacs_repository.py b/custom_components/hacs/api/hacs_repository.py new file mode 100644 index 0000000..80257ea --- /dev/null +++ b/custom_components/hacs/api/hacs_repository.py @@ -0,0 +1,113 @@ +"""API Handler for hacs_repository""" +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from aiogithubapi import AIOGitHubAPIException +from homeassistant.components import websocket_api + +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.share import get_hacs + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository", + vol.Optional("action"): cv.string, + vol.Optional("repository"): cv.string, + } +) +async def hacs_repository(hass, connection, msg): + """Handle get media player cover command.""" + hacs = get_hacs() + logger = getLogger() + data = {} + repository = None + + repo_id = msg.get("repository") + action = msg.get("action") + if repo_id is None or action is None: + return + + try: + repository = hacs.get_by_id(repo_id) + logger.debug(f"Running {action} for {repository.data.full_name}") + + if action == "update": + await repository.update_repository(True) + repository.status.updated_info = True + + elif action == "install": + repository.data.new = False + was_installed = repository.data.installed + await repository.async_install() + if not was_installed: + hass.bus.async_fire("hacs/reload", {"force": True}) + + elif action == "not_new": + repository.data.new = False + + elif action == "uninstall": + repository.data.new = False + await repository.update_repository(True) + await repository.uninstall() + + elif action == "hide": + repository.data.hide = True + + elif action == "unhide": + repository.data.hide = False + + elif action == "show_beta": + repository.data.show_beta = True + await repository.update_repository() + + elif action == "hide_beta": + repository.data.show_beta = False + await repository.update_repository() + + elif action == "toggle_beta": + repository.data.show_beta = not repository.data.show_beta + await repository.update_repository() + + elif action == "delete": + repository.data.show_beta = False + repository.remove() + + elif action == "release_notes": + data = [ + { + "name": x.attributes["name"], + "body": x.attributes["body"], + "tag": x.attributes["tag_name"], + } + for x in repository.releases.objects + ] + + elif action == "set_version": + if msg["version"] == repository.data.default_branch: + repository.data.selected_tag = None + else: + repository.data.selected_tag = msg["version"] + await repository.update_repository() + + hass.bus.async_fire("hacs/reload", {"force": True}) + + else: + logger.error(f"WS action '{action}' is not valid") + + await hacs.data.async_write() + message = None + except AIOGitHubAPIException as exception: + message = exception + except AttributeError as exception: + message = f"Could not use repository with ID {repo_id} ({exception})" + except (Exception, BaseException) as exception: # pylint: disable=broad-except + message = exception + + if message is not None: + logger.error(message) + hass.bus.async_fire("hacs/error", {"message": str(message)}) + + if repository: + repository.state = None + connection.send_message(websocket_api.result_message(msg["id"], data)) diff --git a/custom_components/hacs/api/hacs_repository_data.py b/custom_components/hacs/api/hacs_repository_data.py new file mode 100644 index 0000000..3052e72 --- /dev/null +++ b/custom_components/hacs/api/hacs_repository_data.py @@ -0,0 +1,121 @@ +"""API Handler for hacs_repository_data""" +import sys + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from aiogithubapi import AIOGitHubAPIException +from homeassistant.components import websocket_api + +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.helpers.functions.misc import extract_repository_from_url +from custom_components.hacs.helpers.functions.register_repository import ( + register_repository, +) +from custom_components.hacs.share import get_hacs + +_LOGGER = getLogger() + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/repository/data", + vol.Optional("action"): cv.string, + vol.Optional("repository"): cv.string, + vol.Optional("data"): cv.string, + } +) +async def hacs_repository_data(hass, connection, msg): + """Handle get media player cover command.""" + hacs = get_hacs() + repo_id = msg.get("repository") + action = msg.get("action") + data = msg.get("data") + + if repo_id is None: + return + + if action == "add": + repo_id = extract_repository_from_url(repo_id) + if repo_id is None: + return + + if repo_id in hacs.common.skip: + hacs.common.skip.remove(repo_id) + + if not hacs.get_by_name(repo_id): + try: + registration = await register_repository(repo_id, data.lower()) + if registration is not None: + raise HacsException(registration) + except ( + Exception, + BaseException, + ) as exception: # pylint: disable=broad-except + hass.bus.async_fire( + "hacs/error", + { + "action": "add_repository", + "exception": str(sys.exc_info()[0].__name__), + "message": str(exception), + }, + ) + else: + hass.bus.async_fire( + "hacs/error", + { + "action": "add_repository", + "message": f"Repository '{repo_id}' exists in the store.", + }, + ) + + repository = hacs.get_by_name(repo_id) + else: + repository = hacs.get_by_id(repo_id) + + if repository is None: + hass.bus.async_fire("hacs/repository", {}) + return + + _LOGGER.debug("Running %s for %s", action, repository.data.full_name) + try: + if action == "set_state": + repository.state = data + + elif action == "set_version": + repository.data.selected_tag = data + await repository.update_repository() + + repository.state = None + + elif action == "install": + was_installed = repository.data.installed + repository.data.selected_tag = data + await repository.update_repository() + await repository.async_install() + repository.state = None + if not was_installed: + hass.bus.async_fire("hacs/reload", {"force": True}) + + elif action == "add": + repository.state = None + + else: + repository.state = None + _LOGGER.error("WS action '%s' is not valid", action) + + message = None + except AIOGitHubAPIException as exception: + message = exception + except AttributeError as exception: + message = f"Could not use repository with ID {repo_id} ({exception})" + except (Exception, BaseException) as exception: # pylint: disable=broad-except + message = exception + + if message is not None: + _LOGGER.error(message) + hass.bus.async_fire("hacs/error", {"message": str(message)}) + + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) diff --git a/custom_components/hacs/api/hacs_settings.py b/custom_components/hacs/api/hacs_settings.py new file mode 100644 index 0000000..fe07634 --- /dev/null +++ b/custom_components/hacs/api/hacs_settings.py @@ -0,0 +1,54 @@ +"""API Handler for hacs_settings""" +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.share import get_hacs + +_LOGGER = getLogger() + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/settings", + vol.Optional("action"): cv.string, + vol.Optional("categories"): cv.ensure_list, + } +) +async def hacs_settings(hass, connection, msg): + """Handle get media player cover command.""" + hacs = get_hacs() + + action = msg["action"] + _LOGGER.debug("WS action '%s'", action) + + if action == "set_fe_grid": + hacs.configuration.frontend_mode = "Grid" + + elif action == "onboarding_done": + hacs.configuration.onboarding_done = True + + elif action == "set_fe_table": + hacs.configuration.frontend_mode = "Table" + + elif action == "set_fe_compact_true": + hacs.configuration.frontend_compact = False + + elif action == "set_fe_compact_false": + hacs.configuration.frontend_compact = True + + elif action == "clear_new": + for repo in hacs.repositories: + if repo.data.new and repo.data.category in msg.get("categories", []): + _LOGGER.debug( + "Clearing new flag from '%s'", + repo.data.full_name, + ) + repo.data.new = False + else: + _LOGGER.error("WS action '%s' is not valid", action) + hass.bus.async_fire("hacs/config", {}) + await hacs.data.async_write() + connection.send_message(websocket_api.result_message(msg["id"], {})) diff --git a/custom_components/hacs/api/hacs_status.py b/custom_components/hacs/api/hacs_status.py new file mode 100644 index 0000000..058c201 --- /dev/null +++ b/custom_components/hacs/api/hacs_status.py @@ -0,0 +1,23 @@ +"""API Handler for hacs_status""" +import voluptuous as vol +from homeassistant.components import websocket_api + +from custom_components.hacs.share import get_hacs + + +@websocket_api.async_response +@websocket_api.websocket_command({vol.Required("type"): "hacs/status"}) +async def hacs_status(_hass, connection, msg): + """Handle get media player cover command.""" + hacs = get_hacs() + content = { + "startup": hacs.status.startup, + "background_task": hacs.status.background_task, + "lovelace_mode": hacs.system.lovelace_mode, + "reloading_data": hacs.status.reloading_data, + "upgrading_all": hacs.status.upgrading_all, + "disabled": hacs.system.disabled, + "has_pending_tasks": hacs.queue.has_pending_tasks, + "stage": hacs.stage, + } + connection.send_message(websocket_api.result_message(msg["id"], content)) diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py new file mode 100644 index 0000000..26ae408 --- /dev/null +++ b/custom_components/hacs/base.py @@ -0,0 +1,114 @@ +"""Base HACS class.""" +import logging +from typing import List, Optional +import pathlib + +import attr +from aiogithubapi.github import AIOGitHubAPI +from aiogithubapi.objects.repository import AIOGitHubAPIRepository +from homeassistant.core import HomeAssistant + +from .enums import HacsStage +from .helpers.functions.logger import getLogger +from .models.core import HacsCore +from .models.frontend import HacsFrontend +from .models.system import HacsSystem + + +class HacsCommon: + """Common for HACS.""" + + categories: List = [] + default: List = [] + installed: List = [] + skip: List = [] + + +class HacsStatus: + """HacsStatus.""" + + startup: bool = True + new: bool = False + background_task: bool = False + reloading_data: bool = False + upgrading_all: bool = False + + +@attr.s +class HacsBaseAttributes: + """Base HACS class.""" + + _default: Optional[AIOGitHubAPIRepository] + _github: Optional[AIOGitHubAPI] + _hass: Optional[HomeAssistant] + _repository: Optional[AIOGitHubAPIRepository] + _stage: HacsStage = HacsStage.SETUP + _common: Optional[HacsCommon] + + core: HacsCore = attr.ib(HacsCore) + common: HacsCommon = attr.ib(HacsCommon) + status: HacsStatus = attr.ib(HacsStatus) + frontend: HacsFrontend = attr.ib(HacsFrontend) + log: logging.Logger = getLogger() + system: HacsSystem = attr.ib(HacsSystem) + repositories: List = [] + + +@attr.s +class HacsBase(HacsBaseAttributes): + """Base HACS class.""" + + @property + def stage(self) -> HacsStage: + """Returns a HacsStage object.""" + return self._stage + + @stage.setter + def stage(self, value: HacsStage) -> None: + """Set the value for the stage property.""" + self._stage = value + + @property + def github(self) -> Optional[AIOGitHubAPI]: + """Returns a AIOGitHubAPI object.""" + return self._github + + @github.setter + def github(self, value: AIOGitHubAPI) -> None: + """Set the value for the github property.""" + self._github = value + + @property + def repository(self) -> Optional[AIOGitHubAPIRepository]: + """Returns a AIOGitHubAPIRepository object representing hacs/integration.""" + return self._repository + + @repository.setter + def repository(self, value: AIOGitHubAPIRepository) -> None: + """Set the value for the repository property.""" + self._repository = value + + @property + def default(self) -> Optional[AIOGitHubAPIRepository]: + """Returns a AIOGitHubAPIRepository object representing hacs/default.""" + return self._default + + @default.setter + def default(self, value: AIOGitHubAPIRepository) -> None: + """Set the value for the default property.""" + self._default = value + + @property + def hass(self) -> Optional[HomeAssistant]: + """Returns a HomeAssistant object.""" + return self._hass + + @hass.setter + def hass(self, value: HomeAssistant) -> None: + """Set the value for the default property.""" + self._hass = value + + @property + def integration_dir(self) -> pathlib.Path: + """Return the HACS integration dir.""" + return pathlib.Path(__file__).parent diff --git a/custom_components/hacs/config_flow.py b/custom_components/hacs/config_flow.py new file mode 100644 index 0000000..4d5161f --- /dev/null +++ b/custom_components/hacs/config_flow.py @@ -0,0 +1,153 @@ +"""Adds config flow for HACS.""" +import voluptuous as vol +from aiogithubapi import AIOGitHubAPIException, GitHubDevice +from aiogithubapi.common.const import OAUTH_USER_LOGIN +from awesomeversion import AwesomeVersion +from homeassistant import config_entries +from homeassistant.const import __version__ as HAVERSION +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.event import async_call_later + +from custom_components.hacs.const import CLIENT_ID, DOMAIN, MINIMUM_HA_VERSION +from custom_components.hacs.helpers.functions.configuration_schema import ( + hacs_config_option_schema, +) +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.share import get_hacs + +from .base import HacsBase + +_LOGGER = getLogger() + + +class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for HACS.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + self.device = None + self.activation = None + self._progress_task = None + + async def async_step_user(self, user_input): + """Handle a flow initialized by the user.""" + self._errors = {} + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if self.hass.data.get(DOMAIN): + return self.async_abort(reason="single_instance_allowed") + + if user_input: + if [x for x in user_input if not user_input[x]]: + self._errors["base"] = "acc" + return await self._show_config_form(user_input) + + return await self.async_step_device(user_input) + + ## Initial form + return await self._show_config_form(user_input) + + async def async_step_device(self, _user_input): + """Handle device steps""" + + async def _wait_for_activation(_=None): + self.activation = await self.device.async_device_activation() + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + if not self.activation: + if not self.device: + self.device = GitHubDevice( + CLIENT_ID, + session=aiohttp_client.async_get_clientsession(self.hass), + ) + async_call_later(self.hass, 1, _wait_for_activation) + try: + device_data = await self.device.async_register_device() + return self.async_show_progress( + step_id="device", + progress_action="wait_for_device", + description_placeholders={ + "url": OAUTH_USER_LOGIN, + "code": device_data.user_code, + }, + ) + except AIOGitHubAPIException as exception: + _LOGGER.error(exception) + return self.async_abort(reason="github") + + return self.async_show_progress_done(next_step_id="device_done") + + async def _show_config_form(self, user_input): + """Show the configuration form to edit location data.""" + if not user_input: + user_input = {} + if AwesomeVersion(HAVERSION) < MINIMUM_HA_VERSION: + return self.async_abort( + reason="min_ha_version", + description_placeholders={"version": MINIMUM_HA_VERSION}, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + "acc_logs", default=user_input.get("acc_logs", False) + ): bool, + vol.Required( + "acc_addons", default=user_input.get("acc_addons", False) + ): bool, + vol.Required( + "acc_untested", default=user_input.get("acc_untested", False) + ): bool, + vol.Required( + "acc_disable", default=user_input.get("acc_disable", False) + ): bool, + } + ), + errors=self._errors, + ) + + async def async_step_device_done(self, _user_input): + """Handle device steps""" + return self.async_create_entry( + title="", data={"token": self.activation.access_token} + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return HacsOptionsFlowHandler(config_entry) + + +class HacsOptionsFlowHandler(config_entries.OptionsFlow): + """HACS config flow options handler.""" + + def __init__(self, config_entry): + """Initialize HACS options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, _user_input=None): + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + hacs: HacsBase = get_hacs() + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + if hacs.configuration.config_type == "yaml": + schema = {vol.Optional("not_in_use", default=""): str} + else: + schema = hacs_config_option_schema(self.config_entry.options) + del schema["frontend_repo"] + del schema["frontend_repo_url"] + + return self.async_show_form(step_id="user", data_schema=vol.Schema(schema)) diff --git a/custom_components/hacs/const.py b/custom_components/hacs/const.py new file mode 100644 index 0000000..66e126c --- /dev/null +++ b/custom_components/hacs/const.py @@ -0,0 +1,301 @@ +"""Constants for HACS""" +NAME_LONG = "HACS (Home Assistant Community Store)" +NAME_SHORT = "HACS" +INTEGRATION_VERSION = "1.10.1" +DOMAIN = "hacs" +CLIENT_ID = "395a8e669c5de9f7c6e8" +MINIMUM_HA_VERSION = "2020.12.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" + +IFRAME = { + "title": "HACS", + "icon": "hacs:hacs", + "url": "/community_overview", + "path": "community", + "require_admin": True, +} + +VERSION_STORAGE = "6" +STORENAME = "hacs" + +# 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""" +------------------------------------------------------------------- +HACS (Home Assistant Community Store) + +Version: {INTEGRATION_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 +------------------------------------------------------------------- +""" + +LOCALE = [ + "ALL", + "AF", + "AL", + "DZ", + "AS", + "AD", + "AO", + "AI", + "AQ", + "AG", + "AR", + "AM", + "AW", + "AU", + "AT", + "AZ", + "BS", + "BH", + "BD", + "BB", + "BY", + "BE", + "BZ", + "BJ", + "BM", + "BT", + "BO", + "BQ", + "BA", + "BW", + "BV", + "BR", + "IO", + "BN", + "BG", + "BF", + "BI", + "KH", + "CM", + "CA", + "CV", + "KY", + "CF", + "TD", + "CL", + "CN", + "CX", + "CC", + "CO", + "KM", + "CG", + "CD", + "CK", + "CR", + "HR", + "CU", + "CW", + "CY", + "CZ", + "CI", + "DK", + "DJ", + "DM", + "DO", + "EC", + "EG", + "SV", + "GQ", + "ER", + "EE", + "ET", + "FK", + "FO", + "FJ", + "FI", + "FR", + "GF", + "PF", + "TF", + "GA", + "GM", + "GE", + "DE", + "GH", + "GI", + "GR", + "GL", + "GD", + "GP", + "GU", + "GT", + "GG", + "GN", + "GW", + "GY", + "HT", + "HM", + "VA", + "HN", + "HK", + "HU", + "IS", + "IN", + "ID", + "IR", + "IQ", + "IE", + "IM", + "IL", + "IT", + "JM", + "JP", + "JE", + "JO", + "KZ", + "KE", + "KI", + "KP", + "KR", + "KW", + "KG", + "LA", + "LV", + "LB", + "LS", + "LR", + "LY", + "LI", + "LT", + "LU", + "MO", + "MK", + "MG", + "MW", + "MY", + "MV", + "ML", + "MT", + "MH", + "MQ", + "MR", + "MU", + "YT", + "MX", + "FM", + "MD", + "MC", + "MN", + "ME", + "MS", + "MA", + "MZ", + "MM", + "NA", + "NR", + "NP", + "NL", + "NC", + "NZ", + "NI", + "NE", + "NG", + "NU", + "NF", + "MP", + "NO", + "OM", + "PK", + "PW", + "PS", + "PA", + "PG", + "PY", + "PE", + "PH", + "PN", + "PL", + "PT", + "PR", + "QA", + "RO", + "RU", + "RW", + "RE", + "BL", + "SH", + "KN", + "LC", + "MF", + "PM", + "VC", + "WS", + "SM", + "ST", + "SA", + "SN", + "RS", + "SC", + "SL", + "SG", + "SX", + "SK", + "SI", + "SB", + "SO", + "ZA", + "GS", + "SS", + "ES", + "LK", + "SD", + "SR", + "SJ", + "SZ", + "SE", + "CH", + "SY", + "TW", + "TJ", + "TZ", + "TH", + "TL", + "TG", + "TK", + "TO", + "TT", + "TN", + "TR", + "TM", + "TC", + "TV", + "UG", + "UA", + "AE", + "GB", + "US", + "UM", + "UY", + "UZ", + "VU", + "VE", + "VN", + "VG", + "VI", + "WF", + "EH", + "YE", + "ZM", + "ZW", +] diff --git a/custom_components/hacs/enums.py b/custom_components/hacs/enums.py new file mode 100644 index 0000000..887946a --- /dev/null +++ b/custom_components/hacs/enums.py @@ -0,0 +1,39 @@ +"""Helper constants.""" +# pylint: disable=missing-class-docstring +from enum import Enum + + +class HacsCategory(str, Enum): + APPDAEMON = "appdaemon" + INTEGRATION = "integration" + LOVELACE = "lovelace" + PLUGIN = "plugin" # Kept for legacy purposes + NETDAEMON = "netdaemon" + PYTHON_SCRIPT = "python_script" + THEME = "theme" + REMOVED = "removed" + + +class LovelaceMode(str, Enum): + """Lovelace Modes.""" + + STORAGE = "storage" + AUTO = "auto" + YAML = "yaml" + + +class HacsStage(str, Enum): + SETUP = "setup" + STARTUP = "startup" + WAITING = "waiting" + RUNNING = "running" + BACKGROUND = "background" + + +class HacsSetupTask(str, Enum): + WEBSOCKET = "WebSocket API" + FRONTEND = "Frontend" + SENSOR = "Sensor" + HACS_REPO = "Hacs Repository" + CATEGORIES = "Additional categories" + CLEAR_STORAGE = "Clear storage" diff --git a/custom_components/hacs/hacsbase/__init__.py b/custom_components/hacs/hacsbase/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/hacs/hacsbase/configuration.py b/custom_components/hacs/hacsbase/configuration.py new file mode 100644 index 0000000..825582b --- /dev/null +++ b/custom_components/hacs/hacsbase/configuration.py @@ -0,0 +1,77 @@ +"""HACS Configuration.""" +import attr + +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.functions.logger import getLogger + +_LOGGER = getLogger() + + +@attr.s(auto_attribs=True) +class Configuration: + """Configuration class.""" + + # Main configuration: + appdaemon_path: str = "appdaemon/apps/" + appdaemon: bool = False + netdaemon_path: str = "netdaemon/apps/" + netdaemon: bool = False + config: dict = {} + config_entry: dict = {} + config_type: str = None + debug: bool = False + dev: bool = False + frontend_mode: str = "Grid" + frontend_compact: bool = False + frontend_repo: str = "" + frontend_repo_url: str = "" + options: dict = {} + onboarding_done: bool = False + plugin_path: str = "www/community/" + python_script_path: str = "python_scripts/" + python_script: bool = False + sidepanel_icon: str = "hacs:hacs" + sidepanel_title: str = "HACS" + theme_path: str = "themes/" + theme: bool = False + token: str = None + + # Config options: + country: str = "ALL" + experimental: bool = False + release_limit: int = 5 + + def to_json(self) -> dict: + """Return a dict representation of the configuration.""" + return self.__dict__ + + def print(self) -> None: + """Print the current configuration to the log.""" + config = self.to_json() + for key in config: + if key in ["config", "config_entry", "options", "token"]: + continue + _LOGGER.debug("%s: %s", key, config[key]) + + @staticmethod + def from_dict(configuration: dict, options: dict = None) -> None: + """Set attributes from dicts.""" + if isinstance(options, bool) or isinstance(configuration.get("options"), bool): + raise HacsException("Configuration is not valid.") + + if options is None: + options = {} + + if not configuration: + raise HacsException("Configuration is not valid.") + + config = Configuration() + + config.config = configuration + config.options = options + + for conf_type in [configuration, options]: + for key in conf_type: + setattr(config, key, conf_type[key]) + + return config diff --git a/custom_components/hacs/hacsbase/data.py b/custom_components/hacs/hacsbase/data.py new file mode 100644 index 0000000..a65aa7f --- /dev/null +++ b/custom_components/hacs/hacsbase/data.py @@ -0,0 +1,204 @@ +"""Data handler for HACS.""" +import os + +from queueman import QueueManager + +from custom_components.hacs.const import INTEGRATION_VERSION +from custom_components.hacs.helpers.classes.manifest import HacsManifest +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.helpers.functions.register_repository import ( + register_repository, +) +from custom_components.hacs.helpers.functions.store import ( + async_load_from_store, + async_save_to_store, + get_store_for_key, +) +from custom_components.hacs.share import get_hacs + + +class HacsData: + """HacsData class.""" + + def __init__(self): + """Initialize.""" + self.logger = getLogger() + self.hacs = get_hacs() + self.queue = QueueManager() + self.content = {} + + async def async_write(self): + """Write content to the store files.""" + if self.hacs.status.background_task or self.hacs.system.disabled: + return + + self.logger.debug("Saving data") + + # Hacs + await async_save_to_store( + self.hacs.hass, + "hacs", + { + "view": self.hacs.configuration.frontend_mode, + "compact": self.hacs.configuration.frontend_compact, + "onboarding_done": self.hacs.configuration.onboarding_done, + }, + ) + + # Repositories + self.content = {} + for repository in self.hacs.repositories or []: + self.queue.add(self.async_store_repository_data(repository)) + + if not self.queue.has_pending_tasks: + self.logger.debug("Nothing in the queue") + elif self.queue.running: + self.logger.debug("Queue is already running") + else: + await self.queue.execute() + await async_save_to_store(self.hacs.hass, "repositories", self.content) + self.hacs.hass.bus.async_fire("hacs/repository", {}) + self.hacs.hass.bus.fire("hacs/config", {}) + + async def async_store_repository_data(self, repository): + repository_manifest = repository.repository_manifest.manifest + data = { + "authors": repository.data.authors, + "category": repository.data.category, + "description": repository.data.description, + "domain": repository.data.domain, + "downloads": repository.data.downloads, + "full_name": repository.data.full_name, + "first_install": repository.status.first_install, + "installed_commit": repository.data.installed_commit, + "installed": repository.data.installed, + "last_commit": repository.data.last_commit, + "last_release_tag": repository.data.last_version, + "last_updated": repository.data.last_updated, + "name": repository.data.name, + "new": repository.data.new, + "repository_manifest": repository_manifest, + "selected_tag": repository.data.selected_tag, + "show_beta": repository.data.show_beta, + "stars": repository.data.stargazers_count, + "topics": repository.data.topics, + "version_installed": repository.data.installed_version, + } + if data: + if repository.data.installed and ( + repository.data.installed_commit or repository.data.installed_version + ): + await async_save_to_store( + self.hacs.hass, + f"hacs/{repository.data.id}.hacs", + repository.data.to_json(), + ) + self.content[str(repository.data.id)] = data + + async def restore(self): + """Restore saved data.""" + hacs = await async_load_from_store(self.hacs.hass, "hacs") + repositories = await async_load_from_store(self.hacs.hass, "repositories") + try: + if not hacs and not repositories: + # Assume new install + self.hacs.status.new = True + return True + self.logger.info("Restore started") + self.hacs.status.new = False + + # Hacs + self.hacs.configuration.frontend_mode = hacs.get("view", "Grid") + self.hacs.configuration.frontend_compact = hacs.get("compact", False) + self.hacs.configuration.onboarding_done = hacs.get("onboarding_done", False) + + # Repositories + stores = {} + for entry in repositories or []: + stores[entry] = get_store_for_key(self.hacs.hass, f"hacs/{entry}.hacs") + + stores_exist = {} + + def _populate_stores(): + for entry in repositories or []: + stores_exist[entry] = os.path.exists(stores[entry].path) + + await self.hacs.hass.async_add_executor_job(_populate_stores) + + # Repositories + for entry in repositories or []: + self.queue.add( + self.async_restore_repository( + entry, repositories[entry], stores[entry], stores_exist[entry] + ) + ) + + await self.queue.execute() + + self.logger.info("Restore done") + except (Exception, BaseException) as exception: # pylint: disable=broad-except + self.logger.critical(f"[{exception}] Restore Failed!") + return False + return True + + async def async_restore_repository( + self, entry, repository_data, store, store_exists + ): + if not self.hacs.is_known(entry): + await register_repository( + repository_data["full_name"], repository_data["category"], False + ) + repository = [ + x + for x in self.hacs.repositories + if str(x.data.id) == str(entry) + or x.data.full_name == repository_data["full_name"] + ] + if not repository: + self.logger.error(f"Did not find {repository_data['full_name']} ({entry})") + return + + repository = repository[0] + + # Restore repository attributes + repository.data.id = entry + repository.data.authors = repository_data.get("authors", []) + repository.data.description = repository_data.get("description") + repository.releases.last_release_object_downloads = repository_data.get( + "downloads" + ) + repository.data.last_updated = repository_data.get("last_updated") + repository.data.topics = repository_data.get("topics", []) + repository.data.domain = repository_data.get("domain", None) + repository.data.stargazers_count = repository_data.get("stars", 0) + repository.releases.last_release = repository_data.get("last_release_tag") + repository.data.hide = repository_data.get("hide", False) + repository.data.installed = repository_data.get("installed", False) + repository.data.new = repository_data.get("new", True) + repository.data.selected_tag = repository_data.get("selected_tag") + repository.data.show_beta = repository_data.get("show_beta", False) + repository.data.last_version = repository_data.get("last_release_tag") + repository.data.last_commit = repository_data.get("last_commit") + repository.data.installed_version = repository_data.get("version_installed") + repository.data.installed_commit = repository_data.get("installed_commit") + + repository.repository_manifest = HacsManifest.from_dict( + repository_data.get("repository_manifest", {}) + ) + + if repository.data.installed: + repository.status.first_install = False + + if repository_data["full_name"] == "hacs/integration": + repository.data.installed_version = INTEGRATION_VERSION + repository.data.installed = True + + restored = store_exists and await store.async_load() or {} + + if restored: + repository.data.update_data(restored) + if not repository.data.installed: + repository.logger.debug( + "Should be installed but is not... Fixing that!" + ) + repository.data.installed = True diff --git a/custom_components/hacs/hacsbase/hacs.py b/custom_components/hacs/hacsbase/hacs.py new file mode 100644 index 0000000..44a291d --- /dev/null +++ b/custom_components/hacs/hacsbase/hacs.py @@ -0,0 +1,360 @@ +"""Initialize the HACS base.""" +import json +from datetime import timedelta + +from aiogithubapi import AIOGitHubAPIException +from queueman import QueueManager +from queueman.exceptions import QueueManagerExecutionStillInProgress + +from custom_components.hacs.helpers import HacsHelpers +from custom_components.hacs.helpers.functions.get_list_from_default import ( + async_get_list_from_default, +) +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, +) + +from ..base import HacsBase +from ..enums import HacsCategory, HacsStage + + +class HacsStatus: + """HacsStatus.""" + + startup = True + new = False + background_task = False + reloading_data = False + upgrading_all = False + + +class HacsFrontend: + """HacsFrontend.""" + + version_running = None + version_available = None + version_expected = None + update_pending = False + + +class HacsCommon: + """Common for HACS.""" + + categories = [] + default = [] + installed = [] + skip = [] + + +class System: + """System info.""" + + status = HacsStatus() + config_path = None + ha_version = None + disabled = False + running = False + lovelace_mode = "storage" + + +class Hacs(HacsBase, HacsHelpers): + """The base class of HACS, nested throughout the project.""" + + repositories = [] + repo = None + data_repo = None + data = None + status = HacsStatus() + configuration = None + version = None + session = None + factory = get_factory() + queue = get_queue() + recuring_tasks = [] + common = HacsCommon() + + def get_by_id(self, repository_id): + """Get repository by ID.""" + try: + for repository in self.repositories: + if str(repository.data.id) == str(repository_id): + return repository + except (Exception, BaseException): # pylint: disable=broad-except + pass + return None + + def get_by_name(self, repository_full_name): + """Get repository by full_name.""" + try: + repository_full_name_lower = repository_full_name.lower() + for repository in self.repositories: + if repository.data.full_name_lower == repository_full_name_lower: + return repository + except (Exception, BaseException): # pylint: disable=broad-except + pass + return None + + def is_known(self, repository_id): + """Return a bool if the repository is known.""" + return str(repository_id) in [str(x.data.id) for x in self.repositories] + + @property + def sorted_by_name(self): + """Return a sorted(by name) list of repository objects.""" + return sorted(self.repositories, key=lambda x: x.display_name) + + @property + def sorted_by_repository_name(self): + """Return a sorted(by repository_name) list of repository objects.""" + return sorted(self.repositories, key=lambda x: x.data.full_name) + + async def register_repository(self, full_name, category, check=True): + """Register a repository.""" + await register_repository(full_name, category, check=check) + + 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() + await self.handle_critical_repositories() + await self.async_load_default_repositories() + await self.clear_out_removed_repositories() + + self.recuring_tasks.append( + self.hass.helpers.event.async_track_time_interval( + self.recurring_tasks_installed, timedelta(minutes=30) + ) + ) + self.recuring_tasks.append( + self.hass.helpers.event.async_track_time_interval( + self.recurring_tasks_all, timedelta(minutes=800) + ) + ) + self.recuring_tasks.append( + self.hass.helpers.event.async_track_time_interval( + self.prosess_queue, timedelta(minutes=10) + ) + ) + + self.hass.bus.async_fire("hacs/reload", {"force": True}) + await self.recurring_tasks_installed() + + await self.prosess_queue() + + self.status.startup = False + self.status.background_task = False + self.hass.bus.async_fire("hacs/status", {}) + await self.async_set_stage(HacsStage.RUNNING) + + async def handle_critical_repositories_startup(self): + """Handled critical repositories during startup.""" + alert = False + critical = await async_load_from_store(self.hass, "critical") + if not critical: + return + for repo in critical: + if not repo["acknowledged"]: + alert = True + if alert: + self.log.critical("URGENT!: Check the HACS panel!") + self.hass.components.persistent_notification.create( + title="URGENT!", message="**Check the HACS panel!**" + ) + + async def handle_critical_repositories(self): + """Handled critical repositories during runtime.""" + # Get critical repositories + critical_queue = QueueManager() + instored = [] + critical = [] + was_installed = False + + try: + critical = await self.data_repo.get_contents("critical") + critical = json.loads(critical.content) + except AIOGitHubAPIException: + pass + + if not critical: + self.log.debug("No critical repositories") + return + + stored_critical = await async_load_from_store(self.hass, "critical") + + for stored in stored_critical or []: + instored.append(stored["repository"]) + + stored_critical = [] + + for repository in critical: + removed_repo = get_removed(repository["repository"]) + removed_repo.removal_type = "critical" + repo = self.get_by_name(repository["repository"]) + + stored = { + "repository": repository["repository"], + "reason": repository["reason"], + "link": repository["link"], + "acknowledged": True, + } + if repository["repository"] not in instored: + if repo is not None and repo.installed: + self.log.critical( + "Removing repository %s, it is marked as critical", + repository["repository"], + ) + was_installed = True + stored["acknowledged"] = False + # Remove from HACS + critical_queue.add(repository.uninstall()) + repo.remove() + + stored_critical.append(stored) + removed_repo.update_data(stored) + + # Uninstall + await critical_queue.execute() + + # Save to FS + await async_save_to_store(self.hass, "critical", stored_critical) + + # Restart HASS + if was_installed: + self.log.critical("Resarting Home Assistant") + self.hass.async_create_task(self.hass.async_stop(100)) + + async def prosess_queue(self, _notarealarg=None): + """Recurring tasks for installed repositories.""" + if not self.queue.has_pending_tasks: + self.log.debug("Nothing in the queue") + return + if self.queue.running: + self.log.debug("Queue is already running") + return + + can_update = await get_fetch_updates_for(self.github) + if can_update == 0: + self.log.info("HACS is ratelimited, repository updates will resume later.") + else: + self.status.background_task = True + self.hass.bus.async_fire("hacs/status", {}) + try: + await self.queue.execute(can_update) + except QueueManagerExecutionStillInProgress: + pass + self.status.background_task = False + self.hass.bus.async_fire("hacs/status", {}) + + async def recurring_tasks_installed(self, _notarealarg=None): + """Recurring tasks for installed repositories.""" + self.log.debug("Starting recurring background task for installed repositories") + self.status.background_task = True + self.hass.bus.async_fire("hacs/status", {}) + + for repository in self.repositories: + if ( + repository.data.installed + and repository.data.category in self.common.categories + ): + self.queue.add(self.factory.safe_update(repository)) + + await self.handle_critical_repositories() + self.status.background_task = False + self.hass.bus.async_fire("hacs/status", {}) + await self.data.async_write() + self.log.debug("Recurring background task for installed repositories done") + + 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", {}) + + for repository in self.repositories: + if repository.data.category in self.common.categories: + self.queue.add(self.factory.safe_common_update(repository)) + + await self.async_load_default_repositories() + await self.clear_out_removed_repositories() + self.status.background_task = False + await self.data.async_write() + self.hass.bus.async_fire("hacs/status", {}) + self.hass.bus.async_fire("hacs/repository", {"action": "reload"}) + self.log.debug("Recurring background task for all repositories done") + + async def clear_out_removed_repositories(self): + """Clear out blaclisted repositories.""" + need_to_save = False + for removed in list_removed_repositories(): + repository = self.get_by_name(removed.repository) + if repository is not None: + if repository.data.installed and removed.removal_type != "critical": + self.log.warning( + f"You have {repository.data.full_name} installed with HACS " + + "this repository has been removed, please consider removing it. " + + f"Removal reason ({removed.removal_type})" + ) + else: + need_to_save = True + repository.remove() + + if need_to_save: + await self.data.async_write() + + async def async_load_default_repositories(self): + """Load known repositories.""" + self.log.info("Loading known repositories") + + for item in await async_get_list_from_default(HacsCategory.REMOVED): + removed = get_removed(item["repository"]) + removed.reason = item.get("reason") + removed.link = item.get("link") + removed.removal_type = item.get("removal_type") + + for category in self.common.categories or []: + self.queue.add(self.async_get_category_repositories(HacsCategory(category))) + + await self.prosess_queue() + + async def async_get_category_repositories(self, category: HacsCategory): + """Get repositories from category.""" + repositories = await async_get_list_from_default(category) + for repo in repositories: + if is_removed(repo): + continue + repository = self.get_by_name(repo) + if repository is not None: + if str(repository.data.id) not in self.common.default: + self.common.default.append(str(repository.data.id)) + else: + 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/__init__.py b/custom_components/hacs/helpers/__init__.py new file mode 100644 index 0000000..8c5755c --- /dev/null +++ b/custom_components/hacs/helpers/__init__.py @@ -0,0 +1,17 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from custom_components.hacs.helpers.methods import ( + HacsHelperMethods, + RepositoryHelperMethods, +) +from custom_components.hacs.helpers.properties import RepositoryHelperProperties + + +class RepositoryHelpers( + RepositoryHelperMethods, + RepositoryHelperProperties, +): + """Helper class for repositories""" + + +class HacsHelpers(HacsHelperMethods): + """Helper class for HACS""" diff --git a/custom_components/hacs/helpers/classes/__init__.py b/custom_components/hacs/helpers/classes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/hacs/helpers/classes/exceptions.py b/custom_components/hacs/helpers/classes/exceptions.py new file mode 100644 index 0000000..5a0b796 --- /dev/null +++ b/custom_components/hacs/helpers/classes/exceptions.py @@ -0,0 +1,13 @@ +"""Custom Exceptions.""" + + +class HacsException(Exception): + """Super basic.""" + + +class HacsRepositoryArchivedException(HacsException): + """For repositories that are archived.""" + + +class HacsExpectedException(HacsException): + """For stuff that are expected.""" diff --git a/custom_components/hacs/helpers/classes/manifest.py b/custom_components/hacs/helpers/classes/manifest.py new file mode 100644 index 0000000..f303010 --- /dev/null +++ b/custom_components/hacs/helpers/classes/manifest.py @@ -0,0 +1,43 @@ +""" +Manifest handling of a repository. + +https://hacs.xyz/docs/publish/start#hacsjson +""" +from typing import List + +import attr + +from custom_components.hacs.helpers.classes.exceptions import HacsException + + +@attr.s(auto_attribs=True) +class HacsManifest: + """HacsManifest class.""" + + name: str = None + content_in_root: bool = False + zip_release: bool = False + filename: str = None + manifest: dict = {} + hacs: str = None + hide_default_branch: bool = False + domains: List[str] = [] + country: List[str] = [] + homeassistant: str = None + persistent_directory: str = None + iot_class: str = None + render_readme: bool = False + + @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 = manifest + + for key in manifest: + setattr(manifest_data, key, manifest[key]) + return manifest_data diff --git a/custom_components/hacs/helpers/classes/removed.py b/custom_components/hacs/helpers/classes/removed.py new file mode 100644 index 0000000..fb5b9b9 --- /dev/null +++ b/custom_components/hacs/helpers/classes/removed.py @@ -0,0 +1,21 @@ +"""Object for removed repositories.""" +import attr + + +@attr.s(auto_attribs=True) +class RemovedRepository: + repository: str = None + reason: str = None + link: str = None + removal_type: str = None # archived, not_compliant, critical, dev, broken + acknowledged: bool = False + + def update_data(self, data: dict): + """Update data of the repository.""" + for key in data: + if key in self.__dict__: + setattr(self, key, data[key]) + + def to_json(self): + """Return a JSON representation of the data.""" + return attr.asdict(self) diff --git a/custom_components/hacs/helpers/classes/repository.py b/custom_components/hacs/helpers/classes/repository.py new file mode 100644 index 0000000..695ee4c --- /dev/null +++ b/custom_components/hacs/helpers/classes/repository.py @@ -0,0 +1,435 @@ +"""Repository.""" +# pylint: disable=broad-except, no-member +import json +import os +import tempfile +import zipfile + +from aiogithubapi import AIOGitHubAPIException +from queueman import QueueManager + +from custom_components.hacs.helpers import RepositoryHelpers +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.classes.manifest import HacsManifest +from custom_components.hacs.helpers.classes.repositorydata import RepositoryData +from custom_components.hacs.helpers.classes.validate import Validate +from custom_components.hacs.helpers.functions.download import async_download_file +from custom_components.hacs.helpers.functions.information import ( + get_info_md_content, + get_repository, +) +from custom_components.hacs.helpers.functions.is_safe_to_remove import is_safe_to_remove +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.helpers.functions.misc import get_repository_name +from custom_components.hacs.helpers.functions.save import async_save_file +from custom_components.hacs.helpers.functions.store import async_remove_store +from custom_components.hacs.helpers.functions.validate_repository import ( + common_update_data, + common_validate, +) +from custom_components.hacs.helpers.functions.version_to_install import ( + version_to_install, +) +from custom_components.hacs.share import get_hacs + + +class RepositoryVersions: + """Versions.""" + + available = None + available_commit = None + installed = None + installed_commit = None + + +class RepositoryStatus: + """Repository status.""" + + hide = False + installed = False + last_updated = None + new = True + selected_tag = None + show_beta = False + track = True + updated_info = False + first_install = True + + +class RepositoryInformation: + """RepositoryInformation.""" + + additional_info = None + authors = [] + category = None + default_branch = None + description = "" + state = None + full_name = None + full_name_lower = None + file_name = None + javascript_type = None + homeassistant_version = None + last_updated = None + uid = None + stars = 0 + info = None + name = None + topics = [] + + +class RepositoryReleases: + """RepositoyReleases.""" + + last_release = None + last_release_object = None + last_release_object_downloads = None + published_tags = [] + objects = [] + releases = False + downloads = None + + +class RepositoryPath: + """RepositoryPath.""" + + local = None + remote = None + + +class RepositoryContent: + """RepositoryContent.""" + + path = None + files = [] + objects = [] + single = False + + +class HacsRepository(RepositoryHelpers): + """HacsRepository.""" + + def __init__(self): + """Set up HacsRepository.""" + self.hacs = get_hacs() + self.data = RepositoryData() + self.content = RepositoryContent() + self.content.path = RepositoryPath() + self.information = RepositoryInformation() + self.repository_object = None + self.status = RepositoryStatus() + self.state = None + self.force_branch = False + self.integration_manifest = {} + self.repository_manifest = HacsManifest.from_dict({}) + self.validate = Validate() + self.releases = RepositoryReleases() + self.versions = RepositoryVersions() + self.pending_restart = False + self.tree = [] + self.treefiles = [] + self.ref = None + self.logger = getLogger() + + def __str__(self) -> str: + """Return a string representation of the repository.""" + return f"<{self.data.category.title()} {self.data.full_name}>" + + @property + def display_name(self): + """Return display name.""" + return get_repository_name(self) + + @property + def display_status(self): + """Return display_status.""" + if self.data.new: + status = "new" + elif self.pending_restart: + status = "pending-restart" + elif self.pending_upgrade: + status = "pending-upgrade" + elif self.data.installed: + status = "installed" + else: + status = "default" + return status + + @property + def display_status_description(self): + """Return display_status_description.""" + description = { + "default": "Not installed.", + "pending-restart": "Restart pending.", + "pending-upgrade": "Upgrade pending.", + "installed": "No action required.", + "new": "This is a newly added repository.", + } + return description[self.display_status] + + @property + def display_installed_version(self): + """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 installed + + @property + def display_available_version(self): + """Return display_authors""" + if 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 available + + @property + def display_version_or_commit(self): + """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 main_action(self): + """Return the main action.""" + actions = { + "new": "INSTALL", + "default": "INSTALL", + "installed": "REINSTALL", + "pending-restart": "REINSTALL", + "pending-upgrade": "UPGRADE", + } + return actions[self.display_status] + + async def common_validate(self, ignore_issues=False): + """Common validation steps of the repository.""" + await common_validate(self, ignore_issues) + + async def common_registration(self): + """Common registration steps of the repository.""" + # Attach repository + if self.repository_object is None: + self.repository_object = await get_repository( + self.hacs.session, self.hacs.configuration.token, self.data.full_name + ) + self.data.update_data(self.repository_object.attributes) + + # Set topics + self.data.topics = self.data.topics + + # Set stargazers_count + self.data.stargazers_count = self.data.stargazers_count + + # Set description + self.data.description = self.data.description + + if self.hacs.system.action: + if self.data.description is None or len(self.data.description) == 0: + raise HacsException("::error:: Missing repository description") + + async def common_update(self, ignore_issues=False): + """Common information update steps of the repository.""" + self.logger.debug("%s Getting repository information", self) + + # Attach repository + await common_update_data(self, ignore_issues) + + # Update last updaeted + 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 + await self.get_repository_manifest_content() + + # Update "info.md" + self.information.additional_info = await get_info_md_content(self) + + async def download_zip_files(self, validate): + """Download ZIP archive from repository release.""" + download_queue = QueueManager() + try: + contents = False + + for release in self.releases.objects: + self.logger.info( + "%s ref: %s --- tag: %s.", self, self.ref, release.tag_name + ) + if release.tag_name == self.ref.split("/")[1]: + contents = release.assets + + if not contents: + return validate + + for content in contents or []: + download_queue.add(self.async_download_zip_file(content, validate)) + + await download_queue.execute() + except (Exception, BaseException): + validate.errors.append("Download was not completed") + + return validate + + async def async_download_zip_file(self, content, validate): + """Download ZIP archive from repository release.""" + try: + filecontent = await async_download_file(content.download_url) + + if filecontent is None: + validate.errors.append(f"[{content.name}] was not downloaded") + return + + result = await async_save_file( + f"{tempfile.gettempdir()}/{self.data.filename}", filecontent + ) + with zipfile.ZipFile( + f"{tempfile.gettempdir()}/{self.data.filename}", "r" + ) as zip_file: + zip_file.extractall(self.content.path.local) + + if result: + self.logger.info("%s Download of %s completed", self, content.name) + return + validate.errors.append(f"[{content.name}] was not downloaded") + except (Exception, BaseException): + validate.errors.append("Download was not completed") + + return validate + + async def download_content(self, validate, _directory_path, _local_directory, _ref): + """Download the content of a directory.""" + from custom_components.hacs.helpers.functions.download import download_content + + validate = await download_content(self) + return validate + + async def get_repository_manifest_content(self): + """Get the content of the hacs.json file.""" + if not "hacs.json" in [x.filename for x in self.tree]: + if self.hacs.system.action: + raise HacsException( + "::error:: No hacs.json file in the root of the repository." + ) + return + if self.hacs.system.action: + self.logger.info("%s Found hacs.json", self) + + self.ref = version_to_install(self) + + try: + manifest = await self.repository_object.get_contents("hacs.json", self.ref) + self.repository_manifest = HacsManifest.from_dict( + json.loads(manifest.content) + ) + self.data.update_data(json.loads(manifest.content)) + except (AIOGitHubAPIException, Exception) as exception: # Gotta Catch 'Em All + if self.hacs.system.action: + raise HacsException( + f"::error:: hacs.json file is not valid ({exception})." + ) from None + if self.hacs.system.action: + self.logger.info("%s hacs.json is valid", self) + + def remove(self): + """Run remove tasks.""" + self.logger.info("%s Starting removal", self) + + if self.data.id in self.hacs.common.installed: + self.hacs.common.installed.remove(self.data.id) + for repository in self.hacs.repositories: + if repository.data.id == self.data.id: + self.hacs.repositories.remove(repository) + + async def uninstall(self): + """Run uninstall tasks.""" + self.logger.info("%s Uninstalling", self) + if not await self.remove_local_directory(): + raise HacsException("Could not uninstall") + self.data.installed = False + if self.data.category == "integration": + if self.data.config_flow: + await self.reload_custom_components() + else: + self.pending_restart = True + elif self.data.category == "theme": + try: + await self.hacs.hass.services.async_call( + "frontend", "reload_themes", {} + ) + except (Exception, BaseException): # pylint: disable=broad-except + pass + if self.data.full_name in self.hacs.common.installed: + self.hacs.common.installed.remove(self.data.full_name) + + 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.hass.bus.async_fire( + "hacs/repository", + {"id": 1337, "action": "uninstall", "repository": self.data.full_name}, + ) + + async def remove_local_directory(self): + """Check the local directory.""" + import shutil + from asyncio import sleep + + try: + if self.data.category == "python_script": + local_path = f"{self.content.path.local}/{self.data.name}.py" + elif self.data.category == "theme": + if os.path.exists( + f"{self.hacs.core.config_path}/{self.hacs.configuration.theme_path}/{self.data.name}.yaml" + ): + os.remove( + f"{self.hacs.core.config_path}/{self.hacs.configuration.theme_path}/{self.data.name}.yaml" + ) + local_path = self.content.path.local + elif self.data.category == "integration": + if not self.data.domain: + self.logger.error("%s Missing domain", self) + return False + local_path = self.content.path.local + else: + local_path = self.content.path.local + + if os.path.exists(local_path): + if not is_safe_to_remove(local_path): + self.logger.error( + "%s Path %s is blocked from removal", self, local_path + ) + return False + self.logger.debug("%s Removing %s", self, local_path) + + if self.data.category in ["python_script"]: + os.remove(local_path) + else: + shutil.rmtree(local_path) + + while os.path.exists(local_path): + await sleep(1) + else: + self.logger.debug( + "%s Presumed local content path %s does not exist", self, local_path + ) + + except (Exception, BaseException) as exception: + self.logger.debug( + "%s Removing %s failed with %s", self, local_path, exception + ) + return False + return True diff --git a/custom_components/hacs/helpers/classes/repositorydata.py b/custom_components/hacs/helpers/classes/repositorydata.py new file mode 100644 index 0000000..6741bc6 --- /dev/null +++ b/custom_components/hacs/helpers/classes/repositorydata.py @@ -0,0 +1,128 @@ +"""Repository data.""" +from datetime import datetime +from typing import List + +import attr + + +@attr.s(auto_attribs=True) +class RepositoryData: + """RepositoryData class.""" + + archived: bool = False + authors: List[str] = [] + category: str = "" + content_in_root: bool = False + country: List[str] = [] + config_flow: bool = False + default_branch: str = None + description: str = "" + domain: str = "" + domains: List[str] = [] + downloads: int = 0 + file_name: str = "" + filename: str = "" + first_install: bool = False + fork: bool = False + full_name: str = "" + hacs: str = None # Minimum HACS version + hide: bool = False + hide_default_branch: bool = False + homeassistant: str = None # Minimum Home Assistant version + id: int = 0 + iot_class: str = None + installed: bool = False + installed_commit: str = None + installed_version: str = None + open_issues: int = 0 + last_commit: str = None + last_version: str = None + last_updated: str = 0 + manifest_name: str = None + new: bool = True + persistent_directory: str = None + pushed_at: str = "" + releases: bool = False + render_readme: bool = False + published_tags: List[str] = [] + selected_tag: str = None + show_beta: bool = False + stargazers_count: int = 0 + topics: List[str] = [] + zip_release: bool = False + + @property + def stars(self): + """Return the stargazers count.""" + return self.stargazers_count or 0 + + @property + def name(self): + """Return the name.""" + if self.category in ["integration", "netdaemon"]: + return self.domain + return self.full_name.split("/")[-1] + + def to_json(self): + """Export to json.""" + return attr.asdict(self) + + @staticmethod + def create_from_dict(source: dict): + """Set attributes from dicts.""" + data = RepositoryData() + for key in source: + print(key) + if key in data.__dict__: + if key == "pushed_at": + if source[key] == "": + continue + if "Z" in source[key]: + setattr( + data, + key, + datetime.strptime(source[key], "%Y-%m-%dT%H:%M:%SZ"), + ) + else: + setattr( + data, + key, + datetime.strptime(source[key], "%Y-%m-%dT%H:%M:%S"), + ) + elif key == "id": + setattr(data, key, str(source[key])) + elif key == "country": + if isinstance(source[key], str): + setattr(data, key, [source[key]]) + else: + setattr(data, key, source[key]) + else: + setattr(data, key, source[key]) + return data + + def update_data(self, data: dict): + """Update data of the repository.""" + for key in data: + if key in self.__dict__: + if key == "pushed_at": + if data[key] == "": + continue + if "Z" in data[key]: + setattr( + self, + key, + datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%SZ"), + ) + else: + setattr( + self, key, datetime.strptime(data[key], "%Y-%m-%dT%H:%M:%S") + ) + elif key == "id": + setattr(self, key, str(data[key])) + elif key == "country": + if isinstance(data[key], str): + setattr(self, key, [data[key]]) + else: + setattr(self, key, data[key]) + else: + setattr(self, key, data[key]) diff --git a/custom_components/hacs/helpers/classes/validate.py b/custom_components/hacs/helpers/classes/validate.py new file mode 100644 index 0000000..716da9c --- /dev/null +++ b/custom_components/hacs/helpers/classes/validate.py @@ -0,0 +1,11 @@ +class Validate: + """Validate.""" + + errors = [] + + @property + def success(self): + """Return bool if the validation was a success.""" + if self.errors: + return False + return True diff --git a/custom_components/hacs/helpers/functions/__init__.py b/custom_components/hacs/helpers/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/hacs/helpers/functions/configuration_schema.py b/custom_components/hacs/helpers/functions/configuration_schema.py new file mode 100644 index 0000000..dfe632b --- /dev/null +++ b/custom_components/hacs/helpers/functions/configuration_schema.py @@ -0,0 +1,74 @@ +"""HACS Configuration Schemas.""" +# pylint: disable=dangerous-default-value +import voluptuous as vol + +from custom_components.hacs.const import LOCALE + +# Configuration: +TOKEN = "token" +SIDEPANEL_TITLE = "sidepanel_title" +SIDEPANEL_ICON = "sidepanel_icon" +FRONTEND_REPO = "frontend_repo" +FRONTEND_REPO_URL = "frontend_repo_url" +APPDAEMON = "appdaemon" +NETDAEMON = "netdaemon" + +# Options: +COUNTRY = "country" +DEBUG = "debug" +RELEASE_LIMIT = "release_limit" +EXPERIMENTAL = "experimental" + +# Config group +PATH_OR_URL = "frontend_repo_path_or_url" + + +def hacs_base_config_schema(config: dict = {}) -> dict: + """Return a shcema configuration dict for HACS.""" + if not config: + config = { + TOKEN: "xxxxxxxxxxxxxxxxxxxxxxxxxxx", + } + return { + vol.Required(TOKEN, default=config.get(TOKEN)): str, + } + + +def hacs_config_option_schema(options: dict = {}) -> dict: + """Return a shcema for HACS configuration options.""" + if not options: + options = { + APPDAEMON: False, + COUNTRY: "ALL", + DEBUG: False, + EXPERIMENTAL: False, + NETDAEMON: False, + RELEASE_LIMIT: 5, + SIDEPANEL_ICON: "hacs:hacs", + SIDEPANEL_TITLE: "HACS", + FRONTEND_REPO: "", + FRONTEND_REPO_URL: "", + } + return { + vol.Optional(SIDEPANEL_TITLE, default=options.get(SIDEPANEL_TITLE)): str, + vol.Optional(SIDEPANEL_ICON, default=options.get(SIDEPANEL_ICON)): str, + vol.Optional(RELEASE_LIMIT, default=options.get(RELEASE_LIMIT)): int, + vol.Optional(COUNTRY, default=options.get(COUNTRY)): vol.In(LOCALE), + vol.Optional(APPDAEMON, default=options.get(APPDAEMON)): bool, + vol.Optional(NETDAEMON, default=options.get(NETDAEMON)): bool, + vol.Optional(DEBUG, default=options.get(DEBUG)): bool, + vol.Optional(EXPERIMENTAL, default=options.get(EXPERIMENTAL)): bool, + vol.Exclusive(FRONTEND_REPO, PATH_OR_URL): str, + vol.Exclusive(FRONTEND_REPO_URL, PATH_OR_URL): str, + } + + +def hacs_config_combined() -> dict: + """Combine the configuration options.""" + base = hacs_base_config_schema() + options = hacs_config_option_schema() + + for option in options: + base[option] = options[option] + + return base diff --git a/custom_components/hacs/helpers/functions/constrains.py b/custom_components/hacs/helpers/functions/constrains.py new file mode 100644 index 0000000..93b4e07 --- /dev/null +++ b/custom_components/hacs/helpers/functions/constrains.py @@ -0,0 +1,43 @@ +"""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.system.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/download.py b/custom_components/hacs/helpers/functions/download.py new file mode 100644 index 0000000..18851bf --- /dev/null +++ b/custom_components/hacs/helpers/functions/download.py @@ -0,0 +1,246 @@ +"""Helpers to download repository content.""" +import os +import pathlib +import tempfile +import zipfile + +import async_timeout +import backoff +from queueman import QueueManager, concurrent + +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.functions.filters import ( + filter_content_return_one_of_type, +) +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.helpers.functions.save import async_save_file +from custom_components.hacs.share import get_hacs + +_LOGGER = getLogger() + + +class FileInformation: + def __init__(self, url, path, name): + self.download_url = url + self.path = path + self.name = name + + +@backoff.on_exception(backoff.expo, Exception, max_tries=5) +async def async_download_file(url): + """Download files, and return the content.""" + hacs = get_hacs() + if url is None: + return + + if "tags/" in url: + url = url.replace("tags/", "") + + _LOGGER.debug("Downloading %s", url) + + result = None + + with async_timeout.timeout(60, loop=hacs.hass.loop): + request = await hacs.session.get(url) + + # Make sure that we got a valid result + if request.status == 200: + result = await request.read() + else: + raise HacsException( + "Got status code {} when trying to download {}".format( + request.status, url + ) + ) + + return result + + +def should_try_releases(repository): + """Return a boolean indicating whether to download releases or not.""" + if repository.data.zip_release: + if repository.data.filename.endswith(".zip"): + if repository.ref != repository.data.default_branch: + return True + if repository.ref == repository.data.default_branch: + return False + if repository.data.category not in ["plugin", "theme"]: + return False + if not repository.data.releases: + return False + return True + + +def gather_files_to_download(repository): + """Return a list of file objects to be downloaded.""" + files = [] + tree = repository.tree + ref = f"{repository.ref}".replace("tags/", "") + releaseobjects = repository.releases.objects + category = repository.data.category + remotelocation = repository.content.path.remote + + if should_try_releases(repository): + for release in releaseobjects or []: + if ref == release.tag_name: + for asset in release.assets or []: + files.append(asset) + if files: + return files + + if repository.content.single: + for treefile in tree: + if treefile.filename == repository.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 repository.data.content_in_root: + if not repository.data.filename: + if category == "theme": + tree = filter_content_return_one_of_type( + repository.tree, "", "yaml", "full_path" + ) + + for path in tree: + if path.is_directory: + continue + if path.full_path.startswith(repository.content.path.remote): + files.append( + FileInformation(path.download_url, path.full_path, path.filename) + ) + return files + + +async def download_zip_files(repository, validate): + """Download ZIP archive from repository release.""" + contents = [] + queue = QueueManager() + try: + for release in repository.releases.objects: + repository.logger.info( + f"ref: {repository.ref} --- tag: {release.tag_name}" + ) + if release.tag_name == repository.ref.split("/")[1]: + contents = release.assets + + if not contents: + return validate + + for content in contents or []: + queue.add(async_download_zip_file(repository, content, validate)) + + await queue.execute() + except (Exception, BaseException) as exception: # pylint: disable=broad-except + validate.errors.append(f"Download was not completed [{exception}]") + + return validate + + +async def async_download_zip_file(repository, content, validate): + """Download ZIP archive from repository release.""" + try: + filecontent = await async_download_file(content.download_url) + + if filecontent is None: + validate.errors.append(f"[{content.name}] was not downloaded.") + return + + result = await async_save_file( + f"{tempfile.gettempdir()}/{repository.data.filename}", filecontent + ) + with zipfile.ZipFile( + f"{tempfile.gettempdir()}/{repository.data.filename}", "r" + ) as zip_file: + zip_file.extractall(repository.content.path.local) + + os.remove(f"{tempfile.gettempdir()}/{repository.data.filename}") + + if result: + repository.logger.info(f"Download of {content.name} completed") + return + validate.errors.append(f"[{content.name}] was not downloaded.") + except (Exception, BaseException) as exception: # pylint: disable=broad-except + validate.errors.append(f"Download was not completed [{exception}]") + + return validate + + +async def download_content(repository): + """Download the content of a directory.""" + queue = QueueManager() + contents = gather_files_to_download(repository) + repository.logger.debug(repository.data.filename) + if not contents: + raise HacsException("No content to download") + + for content in contents: + if repository.data.content_in_root and repository.data.filename: + if content.name != repository.data.filename: + continue + queue.add(dowload_repository_content(repository, content)) + await queue.execute() + return repository.validate + + +@concurrent(10) +async def dowload_repository_content(repository, content): + """Download content.""" + repository.logger.debug(f"Downloading {content.name}") + + filecontent = await async_download_file(content.download_url) + + if filecontent is None: + repository.validate.errors.append(f"[{content.name}] was not downloaded.") + return + + # Save the content of the file. + if repository.content.single or content.path is None: + local_directory = repository.content.path.local + + else: + _content_path = content.path + if not repository.data.content_in_root: + _content_path = _content_path.replace( + f"{repository.content.path.remote}", "" + ) + + local_directory = f"{repository.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 async_save_file(local_file_path, filecontent) + if result: + repository.logger.info(f"Download of {content.name} completed") + return + repository.validate.errors.append(f"[{content.name}] was not downloaded.") diff --git a/custom_components/hacs/helpers/functions/filters.py b/custom_components/hacs/helpers/functions/filters.py new file mode 100644 index 0000000..4f98491 --- /dev/null +++ b/custom_components/hacs/helpers/functions/filters.py @@ -0,0 +1,55 @@ +"""Filter functions.""" + + +def filter_content_return_one_of_type( + content, namestartswith, filterfiltype, attr="name" +): + """Only match 1 of the filter.""" + contents = [] + filetypefound = False + for filename in content: + if isinstance(filename, str): + if filename.startswith(namestartswith): + if filename.endswith(f".{filterfiltype}"): + if not filetypefound: + contents.append(filename) + filetypefound = True + continue + else: + contents.append(filename) + else: + if getattr(filename, attr).startswith(namestartswith): + if getattr(filename, attr).endswith(f".{filterfiltype}"): + if not filetypefound: + contents.append(filename) + filetypefound = True + continue + else: + contents.append(filename) + return contents + + +def find_first_of_filetype(content, filterfiltype, attr="name"): + """Find the first of the file type.""" + filename = "" + for _filename in content: + if isinstance(_filename, str): + if _filename.endswith(f".{filterfiltype}"): + filename = _filename + break + else: + if getattr(_filename, attr).endswith(f".{filterfiltype}"): + filename = getattr(_filename, attr) + break + return filename + + +def get_first_directory_in_directory(content, dirname): + """Return the first directory in dirname or None.""" + directory = None + for path in content: + if path.full_path.startswith(dirname) and path.full_path != dirname: + if path.is_directory: + directory = path.filename + break + return directory diff --git a/custom_components/hacs/helpers/functions/get_list_from_default.py b/custom_components/hacs/helpers/functions/get_list_from_default.py new file mode 100644 index 0000000..b2ed428 --- /dev/null +++ b/custom_components/hacs/helpers/functions/get_list_from_default.py @@ -0,0 +1,35 @@ +"""Helper to get default repositories.""" +import json +from typing import List + +from aiogithubapi import AIOGitHubAPIException + +from custom_components.hacs.enums import HacsCategory +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.functions.information import get_repository +from custom_components.hacs.share import get_hacs + + +async def async_get_list_from_default(default: HacsCategory) -> List: + """Get repositories from default list.""" + hacs = get_hacs() + repositories = [] + + try: + repo = await get_repository( + hacs.session, + hacs.configuration.token, + "hacs/default", + ) + content = await repo.get_contents(default, repo.default_branch) + repositories = json.loads(content.content) + + except (AIOGitHubAPIException, HacsException) as exception: + hacs.log.error(exception) + + except (Exception, BaseException) as exception: + hacs.log.error(exception) + + hacs.log.debug("Got %s elements for %s", len(repositories), default) + + return repositories diff --git a/custom_components/hacs/helpers/functions/information.py b/custom_components/hacs/helpers/functions/information.py new file mode 100644 index 0000000..5552248 --- /dev/null +++ b/custom_components/hacs/helpers/functions/information.py @@ -0,0 +1,225 @@ +"""Return repository information if any.""" +import json + +from aiogithubapi import AIOGitHubAPIException, GitHub + +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.functions.template import render_template +from custom_components.hacs.share import get_hacs + + +def info_file(repository): + """get info filename.""" + if repository.data.render_readme: + for filename in ["readme", "readme.md", "README", "README.md", "README.MD"]: + if filename in repository.treefiles: + return filename + return "" + for filename in ["info", "info.md", "INFO", "INFO.md", "INFO.MD"]: + if filename in repository.treefiles: + return filename + return "" + + +async def get_info_md_content(repository): + """Get the content of info.md""" + filename = info_file(repository) + if not filename: + return "" + try: + info = await repository.repository_object.get_contents(filename, repository.ref) + if info is None: + return "" + info = info.content.replace(" bool: + """Helper to check if path is safe to remove.""" + hacs = get_hacs() + paths = [ + Path(f"{hacs.core.config_path}/{hacs.configuration.appdaemon_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.netdaemon_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.plugin_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.python_script_path}"), + Path(f"{hacs.core.config_path}/{hacs.configuration.theme_path}"), + Path(f"{hacs.core.config_path}/custom_components/"), + ] + if Path(path) in paths: + return False + return True diff --git a/custom_components/hacs/helpers/functions/logger.py b/custom_components/hacs/helpers/functions/logger.py new file mode 100644 index 0000000..900bc91 --- /dev/null +++ b/custom_components/hacs/helpers/functions/logger.py @@ -0,0 +1,19 @@ +"""Custom logger for HACS.""" +# pylint: disable=invalid-name +import logging +import os + +from ...const import PACKAGE_NAME + +_HACSLogger: logging.Logger = logging.getLogger(PACKAGE_NAME) + +if "GITHUB_ACTION" in os.environ: + logging.basicConfig( + format="::%(levelname)s:: %(message)s", + level="DEBUG", + ) + + +def getLogger(_name: str = None) -> logging.Logger: + """Return a Logger instance.""" + return _HACSLogger diff --git a/custom_components/hacs/helpers/functions/misc.py b/custom_components/hacs/helpers/functions/misc.py new file mode 100644 index 0000000..5114d6b --- /dev/null +++ b/custom_components/hacs/helpers/functions/misc.py @@ -0,0 +1,42 @@ +"""Helper functions: misc""" +import re +from functools import lru_cache + +from awesomeversion import AwesomeVersion + +RE_REPOSITORY = re.compile( + r"(?:(?:.*github.com.)|^)([A-Za-z0-9-]+\/[\w.-]+?)(?:(?:\.git)?|(?:[^\w.-].*)?)$" +) + + +def get_repository_name(repository) -> str: + """Return the name of the repository for use in the frontend.""" + + if repository.repository_manifest.name is not None: + return repository.repository_manifest.name + + if repository.data.category == "integration": + if repository.integration_manifest: + if "name" in repository.integration_manifest: + return repository.integration_manifest["name"] + + return ( + repository.data.full_name.split("/")[-1] + .replace("-", " ") + .replace("_", " ") + .title() + ) + + +@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) + + +def extract_repository_from_url(url: str) -> str or None: + """Extract the owner/repo part form a URL.""" + match = re.match(RE_REPOSITORY, url) + if not match: + return None + return match.group(1).lower() diff --git a/custom_components/hacs/helpers/functions/path_exsist.py b/custom_components/hacs/helpers/functions/path_exsist.py new file mode 100644 index 0000000..5e941d6 --- /dev/null +++ b/custom_components/hacs/helpers/functions/path_exsist.py @@ -0,0 +1,13 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +import os + +from custom_components.hacs.share import get_hacs + + +def path_exsist(path) -> bool: + return os.path.exists(path) + + +async def async_path_exsist(path) -> bool: + hass = get_hacs().hass + return await hass.async_add_executor_job(path_exsist, path) diff --git a/custom_components/hacs/helpers/functions/register_repository.py b/custom_components/hacs/helpers/functions/register_repository.py new file mode 100644 index 0000000..2fad499 --- /dev/null +++ b/custom_components/hacs/helpers/functions/register_repository.py @@ -0,0 +1,70 @@ +"""Register a repository.""" +from aiogithubapi import AIOGitHubAPIException + +from custom_components.hacs.helpers.classes.exceptions import ( + HacsException, + HacsExpectedException, +) +from custom_components.hacs.share import get_hacs + +from ...repositories import RERPOSITORY_CLASSES + + +# @concurrent(15, 5) +async def register_repository(full_name, category, check=True, ref=None): + """Register a repository.""" + hacs = get_hacs() + + if full_name in hacs.common.skip: + if full_name != "hacs/integration": + raise HacsExpectedException(f"Skipping {full_name}") + + if category not in RERPOSITORY_CLASSES: + raise HacsException(f"{category} is not a valid repository category.") + + repository = RERPOSITORY_CLASSES[category](full_name) + if check: + try: + await repository.async_registration(ref) + if hacs.status.new: + repository.data.new = False + if repository.validate.errors: + hacs.common.skip.append(repository.data.full_name) + if not hacs.status.startup: + hacs.log.error("Validation for %s failed.", full_name) + if hacs.system.action: + raise HacsException(f"::error:: Validation for {full_name} failed.") + return repository.validate.errors + if hacs.system.action: + repository.logger.info("%s Validation completed", repository) + else: + repository.logger.info("%s Registration completed", repository) + except AIOGitHubAPIException as exception: + hacs.common.skip.append(repository.data.full_name) + raise HacsException( + f"Validation for {full_name} failed with {exception}." + ) from None + + exists = ( + False + if str(repository.data.id) == "0" + else [x for x in hacs.repositories if str(x.data.id) == str(repository.data.id)] + ) + + if exists: + if exists[0] in hacs.repositories: + hacs.repositories.remove(exists[0]) + + else: + if hacs.hass is not None and ( + (check and repository.data.new) or hacs.status.new + ): + hacs.hass.bus.async_fire( + "hacs/repository", + { + "action": "registration", + "repository": repository.data.full_name, + "repository_id": repository.data.id, + }, + ) + hacs.repositories.append(repository) diff --git a/custom_components/hacs/helpers/functions/remaining_github_calls.py b/custom_components/hacs/helpers/functions/remaining_github_calls.py new file mode 100644 index 0000000..27a0afe --- /dev/null +++ b/custom_components/hacs/helpers/functions/remaining_github_calls.py @@ -0,0 +1,32 @@ +"""Helper to calculate the remaining calls to github.""" +import math + +from custom_components.hacs.helpers.functions.logger import getLogger + +_LOGGER = getLogger() + + +async def remaining(github): + """Helper to calculate the remaining calls to github.""" + try: + ratelimits = await github.get_rate_limit() + except (BaseException, Exception) as exception: # pylint: disable=broad-except + _LOGGER.error(exception) + return None + if ratelimits.get("remaining") is not None: + return int(ratelimits["remaining"]) + return 0 + + +async def get_fetch_updates_for(github): + """Helper to calculate the number of repositories we can fetch data for.""" + margin = 1000 + limit = await remaining(github) + pr_repo = 15 + + if limit is None: + return None + + if limit - margin <= pr_repo: + return 0 + return math.floor((limit - margin) / pr_repo) diff --git a/custom_components/hacs/helpers/functions/save.py b/custom_components/hacs/helpers/functions/save.py new file mode 100644 index 0000000..05a6e03 --- /dev/null +++ b/custom_components/hacs/helpers/functions/save.py @@ -0,0 +1,52 @@ +"""Download.""" +import gzip +import os +import shutil + +import aiofiles + +from custom_components.hacs.helpers.functions.logger import getLogger + +_LOGGER = getLogger() + + +async def async_save_file(location, content): + """Save files.""" + _LOGGER.debug("Saving %s", location) + mode = "w" + encoding = "utf-8" + errors = "ignore" + + if not isinstance(content, str): + mode = "wb" + encoding = None + errors = None + + try: + async with aiofiles.open( + location, mode=mode, encoding=encoding, errors=errors + ) as outfile: + await outfile.write(content) + outfile.close() + + # Create gz for .js files + if os.path.isfile(location): + if location.endswith(".js") or location.endswith(".css"): + with open(location, "rb") as f_in: + with gzip.open(location + ".gz", "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + + # Remove with 2.0 + if "themes" in location and location.endswith(".yaml"): + filename = location.split("/")[-1] + base = location.split("/themes/")[0] + combined = f"{base}/themes/{filename}" + if os.path.exists(combined): + _LOGGER.info("Removing old theme file %s", combined) + os.remove(combined) + + except (Exception, BaseException) as error: # pylint: disable=broad-except + _LOGGER.error("Could not write data to %s - %s", location, error) + return False + + return os.path.exists(location) diff --git a/custom_components/hacs/helpers/functions/store.py b/custom_components/hacs/helpers/functions/store.py new file mode 100644 index 0000000..6ecdfa2 --- /dev/null +++ b/custom_components/hacs/helpers/functions/store.py @@ -0,0 +1,34 @@ +"""Storage handers.""" +# pylint: disable=import-outside-toplevel +from homeassistant.helpers.json import JSONEncoder + +from custom_components.hacs.const import VERSION_STORAGE + + +def get_store_for_key(hass, key): + """Create a Store object for the key.""" + key = key if "/" in key else f"hacs.{key}" + from homeassistant.helpers.storage import Store + + return Store(hass, VERSION_STORAGE, key, encoder=JSONEncoder) + + +async def async_load_from_store(hass, key): + """Load the retained data from store and return de-serialized data.""" + store = get_store_for_key(hass, key) + restored = await store.async_load() + if restored is None: + return {} + return restored + + +async def async_save_to_store(hass, key, data): + """Generate dynamic data to store and save it to the filesystem.""" + await get_store_for_key(hass, key).async_save(data) + + +async def async_remove_store(hass, key): + """Remove a store element that should no longer be used""" + if "/" not in key: + return + await get_store_for_key(hass, key).async_remove() diff --git a/custom_components/hacs/helpers/functions/template.py b/custom_components/hacs/helpers/functions/template.py new file mode 100644 index 0000000..4f59840 --- /dev/null +++ b/custom_components/hacs/helpers/functions/template.py @@ -0,0 +1,32 @@ +"""Custom template support.""" +# pylint: disable=broad-except +from jinja2 import Template + +from custom_components.hacs.helpers.functions.logger import getLogger + +_LOGGER = getLogger() + + +def render_template(content, context): + """Render templates in content.""" + # Fix None issues + if context.releases.last_release_object is not None: + prerelease = context.releases.last_release_object.prerelease + else: + prerelease = False + + # Render the template + try: + render = Template(content) + render = render.render( + installed=context.data.installed, + pending_update=context.pending_upgrade, + prerelease=prerelease, + selected_tag=context.data.selected_tag, + version_available=context.releases.last_release, + version_installed=context.display_installed_version, + ) + return render + except (Exception, BaseException) as exception: + _LOGGER.debug(exception) + return content diff --git a/custom_components/hacs/helpers/functions/validate_repository.py b/custom_components/hacs/helpers/functions/validate_repository.py new file mode 100644 index 0000000..615670e --- /dev/null +++ b/custom_components/hacs/helpers/functions/validate_repository.py @@ -0,0 +1,101 @@ +"""Helper to do common validation for repositories.""" +from aiogithubapi import AIOGitHubAPIException + +from custom_components.hacs.helpers.classes.exceptions import ( + HacsException, + HacsRepositoryArchivedException, +) +from custom_components.hacs.helpers.functions.information import ( + get_releases, + get_repository, + get_tree, +) +from custom_components.hacs.helpers.functions.version_to_install import ( + version_to_install, +) +from custom_components.hacs.share import get_hacs, is_removed + + +async def common_validate(repository, ignore_issues=False): + """Common validation steps of the repository.""" + repository.validate.errors = [] + + # Make sure the repository exist. + repository.logger.debug("%s Checking repository.", repository) + await common_update_data(repository, ignore_issues) + + # Step 6: Get the content of hacs.json + await repository.get_repository_manifest_content() + + +async def common_update_data(repository, ignore_issues=False): + """Common update data.""" + hacs = get_hacs() + releases = [] + try: + repository_object = await get_repository( + hacs.session, hacs.configuration.token, repository.data.full_name + ) + repository.repository_object = repository_object + repository.data.update_data(repository_object.attributes) + except (AIOGitHubAPIException, HacsException) as exception: + if not hacs.status.startup: + repository.logger.error("%s %s", repository, exception) + if not ignore_issues: + repository.validate.errors.append("Repository does not exist.") + raise HacsException(exception) from None + + # Make sure the repository is not archived. + if repository.data.archived and not ignore_issues: + repository.validate.errors.append("Repository is archived.") + raise HacsRepositoryArchivedException("Repository is archived.") + + # Make sure the repository is not in the blacklist. + if is_removed(repository.data.full_name) and not ignore_issues: + repository.validate.errors.append("Repository is in the blacklist.") + raise HacsException("Repository is in the blacklist.") + + # Get releases. + try: + releases = await get_releases( + repository.repository_object, + repository.data.show_beta, + hacs.configuration.release_limit, + ) + if releases: + repository.data.releases = True + repository.releases.objects = [x for x in releases if not x.draft] + repository.data.published_tags = [ + x.tag_name for x in repository.releases.objects + ] + repository.data.last_version = next(iter(repository.data.published_tags)) + + except (AIOGitHubAPIException, HacsException): + repository.data.releases = False + + if not repository.force_branch: + repository.ref = version_to_install(repository) + if repository.data.releases: + for release in repository.releases.objects or []: + if release.tag_name == repository.ref: + assets = release.assets + if assets: + downloads = next(iter(assets)).attributes.get("download_count") + repository.data.downloads = downloads + + repository.logger.debug( + "%s Running checks against %s", repository, repository.ref.replace("tags/", "") + ) + + try: + repository.tree = await get_tree(repository.repository_object, repository.ref) + if not repository.tree: + raise HacsException("No files in tree") + repository.treefiles = [] + for treefile in repository.tree: + repository.treefiles.append(treefile.full_path) + except (AIOGitHubAPIException, HacsException) as exception: + if not hacs.status.startup: + repository.logger.error("%s %s", repository, exception) + if not ignore_issues: + raise HacsException(exception) from None diff --git a/custom_components/hacs/helpers/functions/version_to_install.py b/custom_components/hacs/helpers/functions/version_to_install.py new file mode 100644 index 0000000..75dd4ba --- /dev/null +++ b/custom_components/hacs/helpers/functions/version_to_install.py @@ -0,0 +1,20 @@ +"""Install helper for repositories.""" + + +def version_to_install(repository): + """Determine which version to isntall.""" + if repository.data.last_version is not None: + if repository.data.selected_tag is not None: + if repository.data.selected_tag == repository.data.last_version: + repository.data.selected_tag = None + return repository.data.last_version + return repository.data.selected_tag + return repository.data.last_version + if repository.data.selected_tag is not None: + if repository.data.selected_tag == repository.data.default_branch: + return repository.data.default_branch + if repository.data.selected_tag in repository.data.published_tags: + return repository.data.selected_tag + if repository.data.default_branch is None: + return "main" + return repository.data.default_branch diff --git a/custom_components/hacs/helpers/methods/__init__.py b/custom_components/hacs/helpers/methods/__init__.py new file mode 100644 index 0000000..26d9422 --- /dev/null +++ b/custom_components/hacs/helpers/methods/__init__.py @@ -0,0 +1,30 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from custom_components.hacs.helpers.methods.installation import ( + RepositoryMethodInstall, + RepositoryMethodPostInstall, + RepositoryMethodPreInstall, +) +from custom_components.hacs.helpers.methods.registration import ( + RepositoryMethodPostRegistration, + RepositoryMethodPreRegistration, + RepositoryMethodRegistration, +) +from custom_components.hacs.helpers.methods.reinstall_if_needed import ( + RepositoryMethodReinstallIfNeeded, +) + + +class RepositoryHelperMethods( + RepositoryMethodReinstallIfNeeded, + RepositoryMethodInstall, + RepositoryMethodPostInstall, + RepositoryMethodPreInstall, + RepositoryMethodPreRegistration, + RepositoryMethodRegistration, + RepositoryMethodPostRegistration, +): + """Collection of repository methods that are nested to all repositories.""" + + +class HacsHelperMethods: + """Helper class for HACS methods""" diff --git a/custom_components/hacs/helpers/methods/installation.py b/custom_components/hacs/helpers/methods/installation.py new file mode 100644 index 0000000..9220582 --- /dev/null +++ b/custom_components/hacs/helpers/methods/installation.py @@ -0,0 +1,117 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +import os +import tempfile +from abc import ABC + +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.functions.download import download_content +from custom_components.hacs.helpers.functions.version_to_install import ( + version_to_install, +) +from custom_components.hacs.operational.backup import Backup, BackupNetDaemon +from custom_components.hacs.share import get_hacs + + +class RepositoryMethodPreInstall(ABC): + async def async_pre_install(self) -> None: + pass + + async def _async_pre_install(self) -> None: + self.logger.info("Running pre installation steps") + await self.async_pre_install() + self.logger.info("Pre installation steps completed") + + +class RepositoryMethodInstall(ABC): + async def async_install(self) -> None: + await self._async_pre_install() + self.logger.info("Running installation steps") + await async_install_repository(self) + self.logger.info("Installation steps completed") + await self._async_post_install() + + +class RepositoryMethodPostInstall(ABC): + async def async_post_installation(self) -> None: + pass + + async def _async_post_install(self) -> None: + self.logger.info("Running post installation steps") + await self.async_post_installation() + self.data.new = False + self.hacs.hass.bus.async_fire( + "hacs/repository", + {"id": 1337, "action": "install", "repository": self.data.full_name}, + ) + self.logger.info("Post installation steps completed") + + +async def async_install_repository(repository): + """Common installation steps of the repository.""" + hacs = get_hacs() + persistent_directory = None + await repository.update_repository() + if repository.content.path.local is None: + raise HacsException("repository.content.path.local is None") + repository.validate.errors = [] + + if not repository.can_install: + raise HacsException( + "The version of Home Assistant is not compatible with this version" + ) + + version = version_to_install(repository) + if version == repository.data.default_branch: + repository.ref = version + else: + repository.ref = f"tags/{version}" + + if repository.data.installed and repository.data.category == "netdaemon": + persistent_directory = await hacs.hass.async_add_executor_job( + BackupNetDaemon, repository + ) + await hacs.hass.async_add_executor_job(persistent_directory.create) + + elif repository.data.persistent_directory: + if os.path.exists( + f"{repository.content.path.local}/{repository.data.persistent_directory}" + ): + persistent_directory = Backup( + f"{repository.content.path.local}/{repository.data.persistent_directory}", + tempfile.gettempdir() + "/hacs_persistent_directory/", + ) + await hacs.hass.async_add_executor_job(persistent_directory.create) + + if repository.data.installed and not repository.content.single: + backup = Backup(repository.content.path.local) + await hacs.hass.async_add_executor_job(backup.create) + + if repository.data.zip_release and version != repository.data.default_branch: + await repository.download_zip_files(repository) + else: + await download_content(repository) + + if repository.validate.errors: + for error in repository.validate.errors: + repository.logger.error(error) + if repository.data.installed and not repository.content.single: + await hacs.hass.async_add_executor_job(backup.restore) + + if repository.data.installed and not repository.content.single: + await hacs.hass.async_add_executor_job(backup.cleanup) + + if persistent_directory is not None: + await hacs.hass.async_add_executor_job(persistent_directory.restore) + await hacs.hass.async_add_executor_job(persistent_directory.cleanup) + + if repository.validate.success: + if repository.data.full_name not in repository.hacs.common.installed: + if repository.data.full_name == "hacs/integration": + repository.hacs.common.installed.append(repository.data.full_name) + repository.data.installed = True + repository.data.installed_commit = repository.data.last_commit + + if version == repository.data.default_branch: + repository.data.installed_version = None + else: + repository.data.installed_version = version diff --git a/custom_components/hacs/helpers/methods/registration.py b/custom_components/hacs/helpers/methods/registration.py new file mode 100644 index 0000000..d6f2288 --- /dev/null +++ b/custom_components/hacs/helpers/methods/registration.py @@ -0,0 +1,43 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member, attribute-defined-outside-init +from abc import ABC + +from custom_components.hacs.validate import async_run_repository_checks + + +class RepositoryMethodPreRegistration(ABC): + async def async_pre_registration(self): + pass + + +class RepositoryMethodRegistration(ABC): + async def registration(self, ref=None) -> None: + self.logger.warning( + "'registration' is deprecated, use 'async_registration' instead" + ) + await self.async_registration(ref) + + async def async_registration(self, ref=None) -> None: + # Run local pre 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() + + +class RepositoryMethodPostRegistration(ABC): + async def async_post_registration(self): + await async_run_repository_checks(self) diff --git a/custom_components/hacs/helpers/methods/reinstall_if_needed.py b/custom_components/hacs/helpers/methods/reinstall_if_needed.py new file mode 100644 index 0000000..a83353b --- /dev/null +++ b/custom_components/hacs/helpers/methods/reinstall_if_needed.py @@ -0,0 +1,12 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from abc import ABC + +from custom_components.hacs.helpers.functions.path_exsist import async_path_exsist + + +class RepositoryMethodReinstallIfNeeded(ABC): + async def async_reinstall_if_needed(self) -> None: + if self.data.installed: + if not await async_path_exsist(self.content.path.local): + self.logger.error("Missing from local FS, should be reinstalled.") + # await self.async_install() diff --git a/custom_components/hacs/helpers/properties/__init__.py b/custom_components/hacs/helpers/properties/__init__.py new file mode 100644 index 0000000..4267caf --- /dev/null +++ b/custom_components/hacs/helpers/properties/__init__.py @@ -0,0 +1,16 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from custom_components.hacs.helpers.properties.can_be_installed import ( + RepositoryPropertyCanBeInstalled, +) +from custom_components.hacs.helpers.properties.custom import RepositoryPropertyCustom +from custom_components.hacs.helpers.properties.pending_update import ( + RepositoryPropertyPendingUpdate, +) + + +class RepositoryHelperProperties( + RepositoryPropertyPendingUpdate, + RepositoryPropertyCustom, + RepositoryPropertyCanBeInstalled, +): + pass diff --git a/custom_components/hacs/helpers/properties/can_be_installed.py b/custom_components/hacs/helpers/properties/can_be_installed.py new file mode 100644 index 0000000..df6b4d6 --- /dev/null +++ b/custom_components/hacs/helpers/properties/can_be_installed.py @@ -0,0 +1,21 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from abc import ABC + +from custom_components.hacs.helpers.functions.misc import version_left_higher_then_right + + +class RepositoryPropertyCanBeInstalled(ABC): + @property + def can_be_installed(self) -> bool: + if self.data.homeassistant is not None: + if self.data.releases: + if not version_left_higher_then_right( + self.hacs.system.ha_version, self.data.homeassistant + ): + return False + return True + + @property + def can_install(self): + """kept for legacy compatibility""" + return self.can_be_installed diff --git a/custom_components/hacs/helpers/properties/custom.py b/custom_components/hacs/helpers/properties/custom.py new file mode 100644 index 0000000..3cb0486 --- /dev/null +++ b/custom_components/hacs/helpers/properties/custom.py @@ -0,0 +1,13 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from abc import ABC + + +class RepositoryPropertyCustom(ABC): + @property + def custom(self): + """Return flag if the repository is custom.""" + if str(self.data.id) in self.hacs.common.default: + return False + if self.data.full_name == "hacs/integration": + return False + return True diff --git a/custom_components/hacs/helpers/properties/pending_update.py b/custom_components/hacs/helpers/properties/pending_update.py new file mode 100644 index 0000000..083930e --- /dev/null +++ b/custom_components/hacs/helpers/properties/pending_update.py @@ -0,0 +1,23 @@ +# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member +from abc import ABC + + +class RepositoryPropertyPendingUpdate(ABC): + @property + def pending_update(self) -> bool: + if not self.can_install: + return False + 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_installed_version != self.display_available_version: + return True + return False + + @property + def pending_upgrade(self) -> bool: + """kept for legacy compatibility""" + return self.pending_update diff --git a/custom_components/hacs/iconset.js b/custom_components/hacs/iconset.js new file mode 100644 index 0000000..ef425f2 --- /dev/null +++ b/custom_components/hacs/iconset.js @@ -0,0 +1,7 @@ +window.customIconsets = window.customIconsets || {}; +window.customIconsets["hacs"] = async () => { + return { + path: + "m 20.064849,22.306912 c -0.0319,0.369835 -0.280561,0.707789 -0.656773,0.918212 -0.280572,0.153036 -0.605773,0.229553 -0.950094,0.229553 -0.0765,0 -0.146661,-0.0064 -0.216801,-0.01275 -0.605774,-0.05739 -1.135016,-0.344329 -1.402827,-0.7588 l 0.784304,-0.516495 c 0.0893,0.146659 0.344331,0.312448 0.707793,0.34433 0.235931,0.02551 0.471852,-0.01913 0.637643,-0.108401 0.101998,-0.05101 0.172171,-0.127529 0.17854,-0.191295 0.0065,-0.08289 -0.0255,-0.369835 -0.733293,-0.439975 -1.013854,-0.09565 -1.645127,-0.688661 -1.568606,-1.460214 0.0319,-0.382589 0.280561,-0.714165 0.663153,-0.930965 0.331571,-0.172165 0.752423,-0.25506 1.166895,-0.210424 0.599382,0.05739 1.128635,0.344329 1.402816,0.7588 l -0.784304,0.510118 c -0.0893,-0.140282 -0.344331,-0.299694 -0.707782,-0.331576 -0.235932,-0.02551 -0.471863,0.01913 -0.637654,0.10202 -0.0956,0.05739 -0.165791,0.133906 -0.17216,0.191295 -0.0255,0.293317 0.465482,0.420847 0.726913,0.439976 v 0.0064 c 1.020234,0.09565 1.638757,0.66953 1.562237,1.460213 z m -7.466854,-0.988354 c 0,-1.192401 0.962855,-2.155249 2.15525,-2.155249 0.599393,0 1.179645,0.25506 1.594117,0.707789 l -0.695033,0.624895 c -0.235931,-0.25506 -0.561133,-0.401718 -0.899084,-0.401718 -0.675903,0 -1.217906,0.542 -1.217906,1.217906 0,0.66953 0.542003,1.217908 1.217906,1.217908 0.337951,0 0.663153,-0.140283 0.899084,-0.401718 l 0.695033,0.631271 c -0.414472,0.452729 -0.988355,0.707788 -1.594117,0.707788 -1.192395,0 -2.15525,-0.969224 -2.15525,-2.148872 z M 8.6573365,23.461054 10.353474,19.14418 h 0.624893 l 1.568618,4.316874 H 11.52037 L 11.265308,22.734136 H 9.964513 l -0.274192,0.726918 z m 1.6833885,-1.68339 h 0.580263 L 10.646796,21.012487 Z M 8.1089536,19.156932 v 4.297745 H 7.1461095 v -1.645131 h -1.606867 v 1.645131 H 4.5763876 v -4.297745 h 0.9628549 v 1.696143 h 1.606867 V 19.156932 Z M 20.115859,4.2997436 C 20.090359,4.159461 19.969198,4.0574375 19.822548,4.0574375 H 14.141102 10.506516 4.8250686 c -0.14665,0 -0.2678112,0.1020202 -0.2933108,0.2423061 L 3.690064,8.8461703 c -0.00651,0.01913 -0.00651,0.03826 -0.00651,0.057391 v 1.5239797 c 0,0.165789 0.133911,0.299694 0.2996911,0.299694 H 4.5762579 20.0711 20.664112 c 0.165781,0 0.299691,-0.133905 0.299691,-0.299694 V 8.8971848 c 0,-0.01913 0,-0.03826 -0.0065,-0.05739 z M 4.5763876,17.358767 c 0,0.184917 0.1466608,0.331577 0.3315819,0.331577 h 5.5985465 3.634586 0.924594 c 0.184911,0 0.331571,-0.14666 0.331571,-0.331577 v -4.744098 c 0,-0.184918 0.146661,-0.331577 0.331582,-0.331577 h 2.894913 c 0.184921,0 0.331582,0.146659 0.331582,0.331577 v 4.744098 c 0,0.184917 0.146661,0.331577 0.331571,0.331577 h 0.446363 c 0.18491,0 0.331571,-0.14666 0.331571,-0.331577 v -5.636804 c 0,-0.184918 -0.146661,-0.331577 -0.331571,-0.331577 H 4.9079695 c -0.1849211,0 -0.3315819,0.146659 -0.3315819,0.331577 z m 1.6578879,-4.852498 h 5.6495565 c 0.15303,0 0.280561,0.12753 0.280561,0.280564 v 3.513438 c 0,0.153036 -0.127531,0.280566 -0.280561,0.280566 H 6.2342755 c -0.1530412,0 -0.2805719,-0.12753 -0.2805719,-0.280566 v -3.513438 c 0,-0.159411 0.1275307,-0.280564 0.2805719,-0.280564 z M 19.790657,3.3879075 H 4.8569594 c -0.1530412,0 -0.2805718,-0.1275296 -0.2805718,-0.2805642 V 1.3665653 C 4.5763876,1.2135296 4.7039182,1.086 4.8569594,1.086 H 19.790657 c 0.153041,0 0.280572,0.1275296 0.280572,0.2805653 v 1.740778 c 0,0.1530346 -0.127531,0.2805642 -0.280572,0.2805642 z", + }; +}; diff --git a/custom_components/hacs/manifest.json b/custom_components/hacs/manifest.json new file mode 100644 index 0000000..1ddefe1 --- /dev/null +++ b/custom_components/hacs/manifest.json @@ -0,0 +1,25 @@ +{ + "codeowners": [ + "@ludeeus" + ], + "config_flow": true, + "dependencies": [ + "http", + "websocket_api", + "frontend", + "persistent_notification", + "lovelace" + ], + "documentation": "https://hacs.xyz/docs/configuration/start", + "domain": "hacs", + "issue_tracker": "https://github.com/hacs/integration/issues", + "name": "HACS", + "requirements": [ + "aiofiles>=0.6.0", + "aiogithubapi>=2.0.0<3.0.0", + "awesomeversion>=20.12.5", + "backoff>=1.10.0", + "hacs_frontend==20210103144316", + "queueman==0.5" + ] +} \ No newline at end of file diff --git a/custom_components/hacs/models/__init__.py b/custom_components/hacs/models/__init__.py new file mode 100644 index 0000000..3fb384a --- /dev/null +++ b/custom_components/hacs/models/__init__.py @@ -0,0 +1 @@ +"""Hacs models.""" diff --git a/custom_components/hacs/models/core.py b/custom_components/hacs/models/core.py new file mode 100644 index 0000000..1cffaa5 --- /dev/null +++ b/custom_components/hacs/models/core.py @@ -0,0 +1,15 @@ +"""HACS Core info.""" +from pathlib import Path + +import attr + +from ..enums import LovelaceMode + + +@attr.s +class HacsCore: + """HACS Core info.""" + + config_path = attr.ib(Path) + ha_version = attr.ib(str) + lovelace_mode = LovelaceMode("storage") diff --git a/custom_components/hacs/models/frontend.py b/custom_components/hacs/models/frontend.py new file mode 100644 index 0000000..1b9d875 --- /dev/null +++ b/custom_components/hacs/models/frontend.py @@ -0,0 +1,10 @@ +"""HacsFrontend.""" + + +class HacsFrontend: + """HacsFrontend.""" + + version_running: bool = None + version_available: bool = None + version_expected: bool = None + update_pending: bool = False diff --git a/custom_components/hacs/models/system.py b/custom_components/hacs/models/system.py new file mode 100644 index 0000000..4be94bd --- /dev/null +++ b/custom_components/hacs/models/system.py @@ -0,0 +1,16 @@ +"""HACS System info.""" +import attr + +from ..const import INTEGRATION_VERSION +from ..enums import HacsStage + + +@attr.s +class HacsSystem: + """HACS System info.""" + + disabled: bool = False + running: bool = False + version: str = INTEGRATION_VERSION + stage: HacsStage = attr.ib(HacsStage) + action: bool = False diff --git a/custom_components/hacs/operational/__init__.py b/custom_components/hacs/operational/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/hacs/operational/backup.py b/custom_components/hacs/operational/backup.py new file mode 100644 index 0000000..4edf8eb --- /dev/null +++ b/custom_components/hacs/operational/backup.py @@ -0,0 +1,124 @@ +"""Backup.""" +import os +import shutil +import tempfile +from time import sleep + +from custom_components.hacs.helpers.functions.is_safe_to_remove import is_safe_to_remove +from custom_components.hacs.helpers.functions.logger import getLogger + +BACKUP_PATH = tempfile.gettempdir() + "/hacs_backup/" + +_LOGGER = getLogger() + + +class Backup: + """Backup.""" + + def __init__(self, local_path, backup_path=BACKUP_PATH): + """initialize.""" + self.local_path = local_path + self.backup_path = backup_path + self.backup_path_full = f"{self.backup_path}{self.local_path.split('/')[-1]}" + + def create(self): + """Create a backup in /tmp""" + if not os.path.exists(self.local_path): + return + if not is_safe_to_remove(self.local_path): + return + if os.path.exists(self.backup_path): + shutil.rmtree(self.backup_path) + while os.path.exists(self.backup_path): + sleep(0.1) + os.makedirs(self.backup_path, exist_ok=True) + + try: + if os.path.isfile(self.local_path): + shutil.copyfile(self.local_path, self.backup_path_full) + os.remove(self.local_path) + else: + shutil.copytree(self.local_path, self.backup_path_full) + shutil.rmtree(self.local_path) + while os.path.exists(self.local_path): + sleep(0.1) + _LOGGER.debug( + "Backup for %s, created in %s", + self.local_path, + self.backup_path_full, + ) + except (Exception, BaseException): # pylint: disable=broad-except + pass + + def restore(self): + """Restore from backup.""" + if not os.path.exists(self.backup_path_full): + return + + if os.path.isfile(self.backup_path_full): + if os.path.exists(self.local_path): + os.remove(self.local_path) + shutil.copyfile(self.backup_path_full, self.local_path) + else: + if os.path.exists(self.local_path): + shutil.rmtree(self.local_path) + while os.path.exists(self.local_path): + sleep(0.1) + shutil.copytree(self.backup_path_full, self.local_path) + _LOGGER.debug( + "Restored %s, from backup %s", self.local_path, self.backup_path_full + ) + + def cleanup(self): + """Cleanup backup files.""" + if os.path.exists(self.backup_path): + shutil.rmtree(self.backup_path) + while os.path.exists(self.backup_path): + sleep(0.1) + _LOGGER.debug("Backup dir %s cleared", self.backup_path) + + +class BackupNetDaemon: + """BackupNetDaemon.""" + + def __init__(self, repository): + """Initialize.""" + self.repository = repository + self.backup_path = ( + tempfile.gettempdir() + "/hacs_persistent_netdaemon/" + repository.data.name + ) + + def create(self): + """Create a backup in /tmp""" + if not is_safe_to_remove(self.repository.content.path.local): + return + if os.path.exists(self.backup_path): + shutil.rmtree(self.backup_path) + while os.path.exists(self.backup_path): + sleep(0.1) + os.makedirs(self.backup_path, exist_ok=True) + + for filename in os.listdir(self.repository.content.path.local): + if filename.endswith(".yaml"): + source_file_name = f"{self.repository.content.path.local}/{filename}" + target_file_name = f"{self.backup_path}/{filename}" + shutil.copyfile(source_file_name, target_file_name) + + def restore(self): + """Create a backup in /tmp""" + if os.path.exists(self.backup_path): + for filename in os.listdir(self.backup_path): + if filename.endswith(".yaml"): + source_file_name = f"{self.backup_path}/{filename}" + target_file_name = ( + f"{self.repository.content.path.local}/{filename}" + ) + shutil.copyfile(source_file_name, target_file_name) + + def cleanup(self): + """Create a backup in /tmp""" + if os.path.exists(self.backup_path): + shutil.rmtree(self.backup_path) + while os.path.exists(self.backup_path): + sleep(0.1) + _LOGGER.debug("Backup dir %s cleared", self.backup_path) diff --git a/custom_components/hacs/operational/factory.py b/custom_components/hacs/operational/factory.py new file mode 100644 index 0000000..02ba490 --- /dev/null +++ b/custom_components/hacs/operational/factory.py @@ -0,0 +1,56 @@ +# pylint: disable=missing-docstring,invalid-name +import asyncio + +from aiogithubapi import AIOGitHubAPIException + +from custom_components.hacs.helpers.classes.exceptions import ( + HacsException, + HacsRepositoryArchivedException, +) +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.helpers.functions.register_repository import ( + register_repository, +) + +max_concurrent_tasks = asyncio.Semaphore(15) +sleeper = 5 + +_LOGGER = getLogger() + + +class HacsTaskFactory: + def __init__(self): + self.tasks = [] + self.running = False + + async def safe_common_update(self, repository): + async with max_concurrent_tasks: + try: + await repository.common_update() + except (AIOGitHubAPIException, HacsException) as exception: + _LOGGER.error("%s - %s", repository.data.full_name, exception) + + # Due to GitHub ratelimits we need to sleep a bit + await asyncio.sleep(sleeper) + + async def safe_update(self, repository): + async with max_concurrent_tasks: + try: + await repository.update_repository() + except HacsRepositoryArchivedException as exception: + _LOGGER.warning("%s - %s", repository.data.full_name, exception) + except (AIOGitHubAPIException, HacsException) as exception: + _LOGGER.error("%s - %s", repository.data.full_name, exception) + + # Due to GitHub ratelimits we need to sleep a bit + await asyncio.sleep(sleeper) + + async def safe_register(self, repo, category): + async with max_concurrent_tasks: + try: + await register_repository(repo, category) + except (AIOGitHubAPIException, HacsException) as exception: + _LOGGER.error("%s - %s", repo, exception) + + # Due to GitHub ratelimits we need to sleep a bit + await asyncio.sleep(sleeper) diff --git a/custom_components/hacs/operational/reload.py b/custom_components/hacs/operational/reload.py new file mode 100644 index 0000000..037887e --- /dev/null +++ b/custom_components/hacs/operational/reload.py @@ -0,0 +1,10 @@ +"""Reload HACS""" + + +async def async_reload_entry(hass, config_entry): + """Reload HACS.""" + from custom_components.hacs.operational.remove import async_remove_entry + from custom_components.hacs.operational.setup import async_setup_entry + + await async_remove_entry(hass, config_entry) + await async_setup_entry(hass, config_entry) diff --git a/custom_components/hacs/operational/remove.py b/custom_components/hacs/operational/remove.py new file mode 100644 index 0000000..84d8232 --- /dev/null +++ b/custom_components/hacs/operational/remove.py @@ -0,0 +1,24 @@ +"""Remove HACS.""" +from custom_components.hacs.share import get_hacs + + +async def async_remove_entry(hass, config_entry): + """Handle removal of an entry.""" + hacs = get_hacs() + hacs.log.info("Disabling HACS") + hacs.log.info("Removing recurring tasks") + for task in hacs.recuring_tasks: + task() + if config_entry.state == "loaded": + hacs.log.info("Removing sensor") + try: + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + except ValueError: + pass + hacs.log.info("Removing sidepanel") + try: + hass.components.frontend.async_remove_panel("hacs") + except AttributeError: + pass + hacs.system.disabled = True + hacs.log.info("HACS is now disabled") diff --git a/custom_components/hacs/operational/runtime.py b/custom_components/hacs/operational/runtime.py new file mode 100644 index 0000000..95f5985 --- /dev/null +++ b/custom_components/hacs/operational/runtime.py @@ -0,0 +1 @@ +"""Runtime...""" diff --git a/custom_components/hacs/operational/setup.py b/custom_components/hacs/operational/setup.py new file mode 100644 index 0000000..904d8ed --- /dev/null +++ b/custom_components/hacs/operational/setup.py @@ -0,0 +1,198 @@ +"""Setup HACS.""" +from aiogithubapi import AIOGitHubAPIException, GitHub +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.const import __version__ as HAVERSION +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.event import async_call_later + +from custom_components.hacs.const import DOMAIN, INTEGRATION_VERSION, STARTUP +from custom_components.hacs.enums import HacsStage +from custom_components.hacs.hacsbase.configuration import Configuration +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 + +try: + from homeassistant.components.lovelace import system_health_info +except ImportError: + from homeassistant.components.lovelace.system_health import system_health_info + + +async def _async_common_setup(hass): + """Common setup stages.""" + hacs = get_hacs() + hacs.hass = hass + hacs.system.running = True + hacs.session = async_create_clientsession(hass) + + +async def async_setup_entry(hass, config_entry): + """Set up this integration using UI.""" + from homeassistant import config_entries + + hacs = get_hacs() + if hass.data.get(DOMAIN) is not None: + return False + if config_entry.source == config_entries.SOURCE_IMPORT: + hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id)) + return False + + await _async_common_setup(hass) + + hacs.configuration = Configuration.from_dict( + config_entry.data, config_entry.options + ) + hacs.configuration.config_type = "flow" + hacs.configuration.config_entry = config_entry + + return await async_startup_wrapper_for_config_entry() + + +async def async_setup(hass, config): + """Set up this integration using yaml.""" + hacs = get_hacs() + if DOMAIN not in config: + return True + if hacs.configuration and hacs.configuration.config_type == "flow": + return True + + await _async_common_setup(hass) + + hacs.configuration = Configuration.from_dict(config[DOMAIN]) + hacs.configuration.config_type = "yaml" + await async_startup_wrapper_for_yaml() + return True + + +async def async_startup_wrapper_for_config_entry(): + """Startup wrapper for ui config.""" + hacs = get_hacs() + hacs.configuration.config_entry.add_update_listener(async_reload_entry) + try: + startup_result = await async_hacs_startup() + except AIOGitHubAPIException: + startup_result = False + if not startup_result: + hacs.system.disabled = True + raise ConfigEntryNotReady + hacs.system.disabled = False + return startup_result + + +async def async_startup_wrapper_for_yaml(_=None): + """Startup wrapper for yaml config.""" + hacs = get_hacs() + try: + startup_result = await async_hacs_startup() + except AIOGitHubAPIException: + startup_result = False + if not startup_result: + hacs.system.disabled = True + hacs.log.info("Could not setup HACS, trying again in 15 min") + async_call_later(hacs.hass, 900, async_startup_wrapper_for_yaml) + return + hacs.system.disabled = False + + +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: + # 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.system.ha_version = HAVERSION + + # Setup websocket API + await async_setup_hacs_websockt_api() + + # Set up frontend + await async_setup_frontend() + + # Clear old storage files + await async_clear_storage() + + hacs.system.lovelace_mode = lovelace_info.get("mode", "yaml") + hacs.system.disabled = False + hacs.github = GitHub( + hacs.configuration.token, async_create_clientsession(hacs.hass) + ) + hacs.data = HacsData() + + can_update = await get_fetch_updates_for(hacs.github) + if can_update is None: + hacs.log.critical("Your GitHub token is not valid") + return False + + if can_update != 0: + hacs.log.debug(f"Can update {can_update} repositories") + else: + hacs.log.info( + "HACS is ratelimited, repository updates will resume when the limit is cleared, this can take up to 1 hour" + ) + return False + + # Check HACS Constrains + if not await hacs.hass.async_add_executor_job(check_constrains): + if hacs.configuration.config_type == "flow": + if hacs.configuration.config_entry is not None: + await async_remove_entry(hacs.hass, hacs.configuration.config_entry) + return False + + # Load HACS + if not await async_load_hacs_repository(): + if hacs.configuration.config_type == "flow": + if hacs.configuration.config_entry is not None: + await async_remove_entry(hacs.hass, hacs.configuration.config_entry) + 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 == "flow": + if hacs.configuration.config_entry is not None: + await async_remove_entry(hacs.hass, hacs.configuration.config_entry) + return False + + # Setup startup tasks + if hacs.status.new or hacs.configuration.config_type == "flow": + async_call_later(hacs.hass, 5, hacs.startup_tasks) + 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 diff --git a/custom_components/hacs/operational/setup_actions/__init__.py b/custom_components/hacs/operational/setup_actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/hacs/operational/setup_actions/categories.py b/custom_components/hacs/operational/setup_actions/categories.py new file mode 100644 index 0000000..9ad5d0c --- /dev/null +++ b/custom_components/hacs/operational/setup_actions/categories.py @@ -0,0 +1,43 @@ +"""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: + if HacsCategory.PYTHON_SCRIPT not in hacs.common.categories: + enable_category(hacs, HacsCategory.PYTHON_SCRIPT) + + if ( + hacs.hass.services._services.get("frontend", {}).get("reload_themes") + is not None + ): + if HacsCategory.THEME not in hacs.common.categories: + 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.""" + hacs.log.debug("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 new file mode 100644 index 0000000..c67aaaf --- /dev/null +++ b/custom_components/hacs/operational/setup_actions/clear_storage.py @@ -0,0 +1,24 @@ +"""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 new file mode 100644 index 0000000..5735959 --- /dev/null +++ b/custom_components/hacs/operational/setup_actions/frontend.py @@ -0,0 +1,110 @@ +from hacs_frontend.version import VERSION as FE_VERSION +from hacs_frontend import locate_dir + +from custom_components.hacs.helpers.functions.logger import getLogger +from custom_components.hacs.webresponses.frontend import HacsFrontendDev +from custom_components.hacs.helpers.functions.information import get_frontend_version +from custom_components.hacs.share import get_hacs + +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()) + + # 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 + hass.http.register_static_path( + URL_BASE, hass.config.path("www/community"), cache_headers=False + ) + + 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": "/hacsfiles/frontend/entrypoint.js", + } + }, + require_admin=True, + ) + + +async def async_serve_frontend(requested_file): + hacs = get_hacs() + requested = requested_file.split("/")[-1] + servefile = None + dev = False + + if hacs.configuration.frontend_repo_url or hacs.configuration.frontend_repo: + dev = True + + if hacs.configuration.frontend_repo_url: + _LOGGER.debug("Serving REMOTE DEVELOPMENT frontend") + try: + request = await hacs.session.get( + f"{hacs.configuration.frontend_repo_url}/{requested}" + ) + if request.status == 200: + result = await request.read() + response = web.Response(body=result) + response.headers["Content-Type"] = "application/javascript" + + return response + except (Exception, BaseException) as exception: + _LOGGER.error(exception) + + elif hacs.configuration.frontend_repo: + _LOGGER.debug("Serving LOCAL DEVELOPMENT frontend") + servefile = f"{hacs.configuration.frontend_repo}/hacs_frontend/{requested}" + else: + servefile = f"{locate_dir()}/{requested}" + + if servefile is None or not await async_path_exsist(servefile): + return web.Response(status=404) + + response = web.FileResponse(servefile) + response.headers["Content-Type"] = "application/javascript" + + if dev: + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-store" + return response diff --git a/custom_components/hacs/operational/setup_actions/load_hacs_repository.py b/custom_components/hacs/operational/setup_actions/load_hacs_repository.py new file mode 100644 index 0000000..da30766 --- /dev/null +++ b/custom_components/hacs/operational/setup_actions/load_hacs_repository.py @@ -0,0 +1,38 @@ +"""Starting setup task: load HACS repository.""" +from custom_components.hacs.const import INTEGRATION_VERSION +from custom_components.hacs.helpers.classes.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.repo = repository.repository_object + hacs.data_repo = await get_repository( + hacs.session, hacs.configuration.token, "hacs/default" + ) + 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 new file mode 100644 index 0000000..13be265 --- /dev/null +++ b/custom_components/hacs/operational/setup_actions/sensor.py @@ -0,0 +1,25 @@ +""""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 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 == "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 new file mode 100644 index 0000000..cf3e5b3 --- /dev/null +++ b/custom_components/hacs/operational/setup_actions/websocket_api.py @@ -0,0 +1,36 @@ +"""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/repositories/__init__.py b/custom_components/hacs/repositories/__init__.py new file mode 100644 index 0000000..91d89c4 --- /dev/null +++ b/custom_components/hacs/repositories/__init__.py @@ -0,0 +1,16 @@ +"""Initialize repositories.""" +from custom_components.hacs.repositories.appdaemon import HacsAppdaemon +from custom_components.hacs.repositories.integration import HacsIntegration +from custom_components.hacs.repositories.netdaemon import HacsNetdaemon +from custom_components.hacs.repositories.plugin import HacsPlugin +from custom_components.hacs.repositories.python_script import HacsPythonScript +from custom_components.hacs.repositories.theme import HacsTheme + +RERPOSITORY_CLASSES = { + "theme": HacsTheme, + "integration": HacsIntegration, + "python_script": HacsPythonScript, + "appdaemon": HacsAppdaemon, + "netdaemon": HacsNetdaemon, + "plugin": HacsPlugin, +} diff --git a/custom_components/hacs/repositories/appdaemon.py b/custom_components/hacs/repositories/appdaemon.py new file mode 100644 index 0000000..78656da --- /dev/null +++ b/custom_components/hacs/repositories/appdaemon.py @@ -0,0 +1,72 @@ +"""Class for appdaemon apps in HACS.""" +from aiogithubapi import AIOGitHubAPIException + +from custom_components.hacs.enums import HacsCategory +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.classes.repository import HacsRepository + + +class HacsAppdaemon(HacsRepository): + """Appdaemon apps in HACS.""" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.data.full_name = full_name + self.data.full_name_lower = full_name.lower() + self.data.category = HacsCategory.APPDAEMON + self.content.path.local = self.localpath + self.content.path.remote = "apps" + + @property + def localpath(self): + """Return localpath.""" + return f"{self.hacs.core.config_path}/appdaemon/apps/{self.data.name}" + + async def validate_repository(self): + """Validate.""" + await self.common_validate() + + # Custom step 1: Validate content. + try: + addir = await self.repository_object.get_contents("apps", self.ref) + except AIOGitHubAPIException: + raise HacsException( + f"Repostitory structure for {self.ref.replace('tags/','')} is not compliant" + ) from None + + if not isinstance(addir, list): + self.validate.errors.append("Repostitory structure not compliant") + + self.content.path.remote = addir[0].path + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.hacs.status.startup: + self.logger.error("%s %s", self, error) + return self.validate.success + + async def update_repository(self, ignore_issues=False): + """Update.""" + await self.common_update(ignore_issues) + + # Get appdaemon objects. + if self.repository_manifest: + if self.data.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "apps": + addir = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + self.content.path.remote = addir[0].path + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + + # Set local path + self.content.path.local = self.localpath diff --git a/custom_components/hacs/repositories/integration.py b/custom_components/hacs/repositories/integration.py new file mode 100644 index 0000000..9f3f1f7 --- /dev/null +++ b/custom_components/hacs/repositories/integration.py @@ -0,0 +1,97 @@ +"""Class for integrations in HACS.""" +from homeassistant.loader import async_get_custom_components + +from custom_components.hacs.enums import HacsCategory +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.classes.repository import HacsRepository +from custom_components.hacs.helpers.functions.filters import ( + get_first_directory_in_directory, +) +from custom_components.hacs.helpers.functions.information import ( + get_integration_manifest, +) +from custom_components.hacs.helpers.functions.logger import getLogger + + +class HacsIntegration(HacsRepository): + """Integrations in HACS.""" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.data.full_name = full_name + self.data.full_name_lower = full_name.lower() + self.data.category = HacsCategory.INTEGRATION + self.content.path.remote = "custom_components" + self.content.path.local = self.localpath + + @property + def localpath(self): + """Return localpath.""" + return f"{self.hacs.core.config_path}/custom_components/{self.data.domain}" + + async def async_post_installation(self): + """Run post installation steps.""" + if self.data.config_flow: + if self.data.full_name != "hacs/integration": + await self.reload_custom_components() + if self.data.first_install: + self.pending_restart = False + return + self.pending_restart = True + + async def validate_repository(self): + """Validate.""" + await self.common_validate() + + # Custom step 1: Validate content. + if self.data.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "custom_components": + name = get_first_directory_in_directory(self.tree, "custom_components") + if name is None: + raise HacsException( + f"Repostitory structure for {self.ref.replace('tags/','')} is not compliant" + ) + self.content.path.remote = f"custom_components/{name}" + + try: + await get_integration_manifest(self) + except HacsException as exception: + if self.hacs.system.action: + raise HacsException(f"::error:: {exception}") from exception + self.logger.error("%s %s", self, exception) + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.hacs.status.startup: + self.logger.error("%s %s", self, error) + return self.validate.success + + async def update_repository(self, ignore_issues=False): + """Update.""" + await self.common_update(ignore_issues) + + if self.data.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "custom_components": + name = get_first_directory_in_directory(self.tree, "custom_components") + self.content.path.remote = f"custom_components/{name}" + + try: + await get_integration_manifest(self) + except HacsException as exception: + self.logger.error("%s %s", self, exception) + + # Set local path + self.content.path.local = self.localpath + + async def reload_custom_components(self): + """Reload custom_components (and config flows)in HA.""" + self.logger.info("Reloading custom_component cache") + del self.hacs.hass.data["custom_components"] + await async_get_custom_components(self.hacs.hass) + self.logger.info("Custom_component cache reloaded") diff --git a/custom_components/hacs/repositories/netdaemon.py b/custom_components/hacs/repositories/netdaemon.py new file mode 100644 index 0000000..7e7fa93 --- /dev/null +++ b/custom_components/hacs/repositories/netdaemon.py @@ -0,0 +1,87 @@ +"""Class for netdaemon apps in HACS.""" +from custom_components.hacs.enums import HacsCategory +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.classes.repository import HacsRepository +from custom_components.hacs.helpers.functions.filters import ( + get_first_directory_in_directory, +) +from custom_components.hacs.helpers.functions.logger import getLogger + + +class HacsNetdaemon(HacsRepository): + """Netdaemon apps in HACS.""" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.data.full_name = full_name + self.data.full_name_lower = full_name.lower() + self.data.category = HacsCategory.NETDAEMON + self.content.path.local = self.localpath + self.content.path.remote = "apps" + + @property + def localpath(self): + """Return localpath.""" + return f"{self.hacs.core.config_path}/netdaemon/apps/{self.data.name}" + + async def validate_repository(self): + """Validate.""" + await self.common_validate() + + # Custom step 1: Validate content. + if self.repository_manifest: + if self.data.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "apps": + self.data.domain = get_first_directory_in_directory( + self.tree, self.content.path.remote + ) + self.content.path.remote = f"apps/{self.data.name}" + + compliant = False + for treefile in self.treefiles: + if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith( + ".cs" + ): + compliant = True + break + if not compliant: + raise HacsException( + f"Repostitory structure for {self.ref.replace('tags/','')} is not compliant" + ) + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.hacs.status.startup: + self.logger.error("%s %s", self, error) + return self.validate.success + + async def update_repository(self, ignore_issues=False): + """Update.""" + await self.common_update(ignore_issues) + + # Get appdaemon objects. + if self.repository_manifest: + if self.data.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "apps": + self.data.domain = get_first_directory_in_directory( + self.tree, self.content.path.remote + ) + self.content.path.remote = f"apps/{self.data.name}" + + # Set local path + self.content.path.local = self.localpath + + async def async_post_installation(self): + """Run post installation steps.""" + try: + await self.hacs.hass.services.async_call( + "hassio", "addon_restart", {"addon": "c6a2317c_netdaemon"} + ) + except (Exception, BaseException): # pylint: disable=broad-except + pass diff --git a/custom_components/hacs/repositories/plugin.py b/custom_components/hacs/repositories/plugin.py new file mode 100644 index 0000000..8b961d6 --- /dev/null +++ b/custom_components/hacs/repositories/plugin.py @@ -0,0 +1,77 @@ +"""Class for plugins in HACS.""" +import json + +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.classes.repository import HacsRepository +from custom_components.hacs.helpers.functions.information import find_file_name +from custom_components.hacs.helpers.functions.logger import getLogger + + +class HacsPlugin(HacsRepository): + """Plugins in HACS.""" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.data.full_name = full_name + self.data.full_name_lower = full_name.lower() + self.data.file_name = None + self.data.category = "plugin" + self.information.javascript_type = None + self.content.path.local = self.localpath + + @property + def localpath(self): + """Return localpath.""" + return f"{self.hacs.core.config_path}/www/community/{self.data.full_name.split('/')[-1]}" + + async def validate_repository(self): + """Validate.""" + # Run common validation steps. + await self.common_validate() + + # Custom step 1: Validate content. + find_file_name(self) + + if self.content.path.remote is None: + raise HacsException( + f"Repostitory structure for {self.ref.replace('tags/','')} is not compliant" + ) + + if self.content.path.remote == "release": + self.content.single = True + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.hacs.status.startup: + self.logger.error("%s %s", self, error) + return self.validate.success + + async def update_repository(self, ignore_issues=False): + """Update.""" + await self.common_update(ignore_issues) + + # Get plugin objects. + find_file_name(self) + + if self.content.path.remote is None: + self.validate.errors.append( + f"Repostitory structure for {self.ref.replace('tags/','')} is not compliant" + ) + + if self.content.path.remote == "release": + self.content.single = True + + async def get_package_content(self): + """Get package content.""" + try: + package = await self.repository_object.get_contents( + "package.json", self.ref + ) + package = json.loads(package.content) + + if package: + self.data.authors = package["author"] + except (Exception, BaseException): # pylint: disable=broad-except + pass diff --git a/custom_components/hacs/repositories/python_script.py b/custom_components/hacs/repositories/python_script.py new file mode 100644 index 0000000..6bcf969 --- /dev/null +++ b/custom_components/hacs/repositories/python_script.py @@ -0,0 +1,83 @@ +"""Class for python_scripts in HACS.""" +from custom_components.hacs.enums import HacsCategory +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.classes.repository import HacsRepository +from custom_components.hacs.helpers.functions.information import find_file_name +from custom_components.hacs.helpers.functions.logger import getLogger + + +class HacsPythonScript(HacsRepository): + """python_scripts in HACS.""" + + category = "python_script" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.data.full_name = full_name + self.data.full_name_lower = full_name.lower() + self.data.category = HacsCategory.PYTHON_SCRIPT + self.content.path.remote = "python_scripts" + self.content.path.local = self.localpath + self.content.single = True + + @property + def localpath(self): + """Return localpath.""" + return f"{self.hacs.core.config_path}/python_scripts" + + async def validate_repository(self): + """Validate.""" + # Run common validation steps. + await self.common_validate() + + # Custom step 1: Validate content. + if self.data.content_in_root: + self.content.path.remote = "" + + compliant = False + for treefile in self.treefiles: + if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith( + ".py" + ): + compliant = True + break + if not compliant: + raise HacsException( + f"Repository structure for {self.ref.replace('tags/','')} is not compliant" + ) + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.hacs.status.startup: + self.logger.error("%s %s", self, error) + return self.validate.success + + async def async_post_registration(self): + """Registration.""" + # Set name + find_file_name(self) + + async def update_repository(self, ignore_issues=False): + """Update.""" + await self.common_update(ignore_issues) + + # Get python_script objects. + if self.data.content_in_root: + self.content.path.remote = "" + + compliant = False + for treefile in self.treefiles: + if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith( + ".py" + ): + compliant = True + break + if not compliant: + raise HacsException( + f"Repository structure for {self.ref.replace('tags/','')} is not compliant" + ) + + # Update name + find_file_name(self) diff --git a/custom_components/hacs/repositories/theme.py b/custom_components/hacs/repositories/theme.py new file mode 100644 index 0000000..37e8d6d --- /dev/null +++ b/custom_components/hacs/repositories/theme.py @@ -0,0 +1,76 @@ +"""Class for themes in HACS.""" +from custom_components.hacs.enums import HacsCategory +from custom_components.hacs.helpers.classes.exceptions import HacsException +from custom_components.hacs.helpers.classes.repository import HacsRepository +from custom_components.hacs.helpers.functions.information import find_file_name +from custom_components.hacs.helpers.functions.logger import getLogger + + +class HacsTheme(HacsRepository): + """Themes in HACS.""" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.data.full_name = full_name + self.data.full_name_lower = full_name.lower() + self.data.category = HacsCategory.THEME + self.content.path.remote = "themes" + self.content.path.local = self.localpath + self.content.single = False + + @property + def localpath(self): + """Return localpath.""" + return f"{self.hacs.core.config_path}/themes/{self.data.file_name.replace('.yaml', '')}" + + async def async_post_installation(self): + """Run post installation steps.""" + try: + await self.hacs.hass.services.async_call("frontend", "reload_themes", {}) + except (Exception, BaseException): # pylint: disable=broad-except + pass + + async def validate_repository(self): + """Validate.""" + # Run common validation steps. + await self.common_validate() + + # Custom step 1: Validate content. + compliant = False + for treefile in self.treefiles: + if treefile.startswith("themes/") and treefile.endswith(".yaml"): + compliant = True + break + if not compliant: + raise HacsException( + f"Repostitory structure for {self.ref.replace('tags/','')} is not compliant" + ) + + if self.data.content_in_root: + self.content.path.remote = "" + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.hacs.status.startup: + self.logger.error("%s %s", self, error) + return self.validate.success + + async def async_post_registration(self): + """Registration.""" + # Set name + find_file_name(self) + self.content.path.local = self.localpath + + async def update_repository(self, ignore_issues=False): + """Update.""" + await self.common_update(ignore_issues) + + # Get theme objects. + if self.data.content_in_root: + self.content.path.remote = "" + + # Update name + find_file_name(self) + self.content.path.local = self.localpath diff --git a/custom_components/hacs/sensor.py b/custom_components/hacs/sensor.py new file mode 100644 index 0000000..d5f6cea --- /dev/null +++ b/custom_components/hacs/sensor.py @@ -0,0 +1,123 @@ +"""Sensor platform for HACS.""" +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.share import get_hacs + + +async def async_setup_platform( + _hass, _config, async_add_entities, _discovery_info=None +): + """Setup sensor platform.""" + async_add_entities([HACSSensor()]) + + +async def async_setup_entry(_hass, _config_entry, async_add_devices): + """Setup sensor platform.""" + async_add_devices([HACSSensor()]) + + +class HACSDevice(Entity): + """HACS Device class.""" + + @property + def device_info(self): + """Return device information about HACS.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": NAME_SHORT, + "manufacturer": "hacs.xyz", + "model": "", + "sw_version": INTEGRATION_VERSION, + "entry_type": "service", + } + + +class HACSSensor(HACSDevice): + """HACS Sensor class.""" + + def __init__(self): + """Initialize.""" + self._state = None + self.repositories = [] + + @property + def should_poll(self): + """No polling needed.""" + return False + + async def async_update(self): + """Manual updates of the sensor.""" + self._update() + + @callback + def _update_and_write_state(self, *_): + """Update the sensor and write state.""" + self._update() + self.async_write_ha_state() + + @callback + def _update(self): + """Update the sensor.""" + hacs = get_hacs() + if hacs.status.background_task: + return + + self.repositories = [] + + for repository in hacs.repositories: + if ( + repository.pending_upgrade + and repository.data.category in hacs.common.categories + ): + self.repositories.append(repository) + self._state = len(self.repositories) + + @property + def unique_id(self): + """Return a unique ID to use for this sensor.""" + return ( + "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd" + ) + + @property + def name(self): + """Return the name of the sensor.""" + return "hacs" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon of the sensor.""" + return "hacs:hacs" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "pending update(s)" + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + repositories = [] + for repository in self.repositories: + repositories.append( + { + "name": repository.data.full_name, + "display_name": repository.display_name, + "installed_version": repository.display_installed_version, + "available_version": repository.display_available_version, + } + ) + return {"repositories": repositories} + + async def async_added_to_hass(self) -> None: + """Register for status events.""" + self.async_on_remove( + self.hass.bus.async_listen("hacs/status", self._update_and_write_state) + ) diff --git a/custom_components/hacs/share.py b/custom_components/hacs/share.py new file mode 100644 index 0000000..a3665c2 --- /dev/null +++ b/custom_components/hacs/share.py @@ -0,0 +1,68 @@ +"""Shared HACS elements.""" +import os + +from .base import HacsBase + +SHARE = { + "hacs": None, + "factory": None, + "queue": None, + "removed_repositories": [], + "rules": {}, +} + + +def get_hacs() -> HacsBase: + if SHARE["hacs"] is None: + from custom_components.hacs.hacsbase.hacs import Hacs as Legacy + + _hacs = Legacy() + + if not "PYTEST" in os.environ and "GITHUB_ACTION" in os.environ: + _hacs.system.action = True + + SHARE["hacs"] = _hacs + + return SHARE["hacs"] + + +def get_factory(): + if SHARE["factory"] is None: + from custom_components.hacs.operational.factory import HacsTaskFactory + + SHARE["factory"] = HacsTaskFactory() + + return SHARE["factory"] + + +def get_queue(): + if SHARE["queue"] is None: + from queueman import QueueManager + + SHARE["queue"] = QueueManager() + + return SHARE["queue"] + + +def is_removed(repository): + return repository in [x.repository for x in SHARE["removed_repositories"]] + + +def get_removed(repository): + if not is_removed(repository): + from custom_components.hacs.helpers.classes.removed import RemovedRepository + + removed_repo = RemovedRepository() + removed_repo.repository = repository + SHARE["removed_repositories"].append(removed_repo) + filter_repos = [ + x + for x in SHARE["removed_repositories"] + if x.repository.lower() == repository.lower() + ] + + return filter_repos.pop() or None + + +def list_removed_repositories(): + return SHARE["removed_repositories"] diff --git a/custom_components/hacs/system_health.py b/custom_components/hacs/system_health.py new file mode 100644 index 0000000..37c03d1 --- /dev/null +++ b/custom_components/hacs/system_health.py @@ -0,0 +1,37 @@ +"""Provide info to system health.""" +from aiogithubapi.common.const import BASE_API_URL +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + +from .base import HacsBase +from .const import DOMAIN + +GITHUB_STATUS = "https://www.githubstatus.com/" + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.domain = "Home Assistant Community Store" + register.async_register_info(system_health_info, "/hacs") + + +async def system_health_info(hass): + """Get info for the info page.""" + client: HacsBase = hass.data[DOMAIN] + rate_limit = await client.github.get_rate_limit() + + return { + "GitHub API": system_health.async_check_can_reach_url( + hass, BASE_API_URL, GITHUB_STATUS + ), + "Github API Calls Remaining": rate_limit.get("remaining", "0"), + "Installed Version": client.version, + "Stage": client.stage, + "Available Repositories": len(client.repositories), + "Installed Repositories": len( + [repo for repo in client.repositories if repo.data.installed] + ), + } diff --git a/custom_components/hacs/translations/en.json b/custom_components/hacs/translations/en.json new file mode 100644 index 0000000..e08c6bf --- /dev/null +++ b/custom_components/hacs/translations/en.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only a single configuration of HACS is allowed.", + "min_ha_version": "You need at least version {version} of Home Assistant to setup HACS.", + "github": "Could not authenticate with GitHub, try again later." + }, + "error": { + "auth": "Personal Access Token is not correct", + "acc": "You need to acknowledge all the statements before continuing" + }, + "step": { + "user": { + "data": { + "acc_logs": "I know how to access Home Assistant logs", + "acc_addons": "I know that there are no add-ons in HACS", + "acc_untested": "I know that everything inside HACS is custom and untested by Home Assistant", + "acc_disable": "I know that if I get issues with Home Assistant I should disable all my custom_components" + }, + "description": "Before you can setup HACS you need to acknowledge the following", + "title": "HACS" + }, + "device": { + "title": "Waiting for device activation" + } + }, + "progress": { + "wait_for_device": "1. Open {url} \n2.Paste the following key to authorize HACS: \n```\n{code}\n```\n" + } + }, + "options": { + "step": { + "user": { + "data": { + "not_in_use": "Not in use with YAML", + "country": "Filter with country code.", + "experimental": "Enable experimental features", + "release_limit": "Number of releases to show.", + "debug": "Enable debug.", + "appdaemon": "Enable AppDaemon apps discovery & tracking", + "netdaemon": "Enable NetDaemon apps discovery & tracking", + "sidepanel_icon": "Side panel icon", + "sidepanel_title": "Side panel title" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/hacs/validate/README.md b/custom_components/hacs/validate/README.md new file mode 100644 index 0000000..e0a7fd3 --- /dev/null +++ b/custom_components/hacs/validate/README.md @@ -0,0 +1,38 @@ +# Repository validation + +This is where the validation rules that run against the various repository categories live. + +## Structure + +- All validation rules are in the directory for their category. +- Validation rules that aplies to all categories are in the `common` directory. +- There is one file pr. rule. +- All rule needs tests to verify every possible outcome for the rule. +- It's better with multiple files than a big rule. +- All rules uses `ValidationBase` or `ActionValidationBase` as the base class. +- The `ActionValidationBase` are for checks that will breaks compatibility with with existing repositories (default), so these are only run in github actions. +- The class name should describe what the check does. +- Only use `validate` or `async_validate` methods to define validation rules. +- If a rule should fail, raise `ValidationException` with the failure message. + + +## Example + +```python +from custom_components.hacs.validate.base import ( + ActionValidationBase, + ValidationBase, + ValidationException, +) + + +class AwesomeRepository(ValidationBase): + def validate(self): + if self.repository != "awesome": + raise ValidationException("The repository is not awesome") + +class SuperAwesomeRepository(ActionValidationBase, category="integration"): + async def async_validate(self): + if self.repository != "super-awesome": + raise ValidationException("The repository is not super-awesome") +``` \ No newline at end of file diff --git a/custom_components/hacs/validate/__init__.py b/custom_components/hacs/validate/__init__.py new file mode 100644 index 0000000..42ec85f --- /dev/null +++ b/custom_components/hacs/validate/__init__.py @@ -0,0 +1,51 @@ +import asyncio +import glob +import importlib +from os.path import dirname, join, sep + +from custom_components.hacs.share import SHARE, get_hacs + + +def _initialize_rules(): + rules = glob.glob(join(dirname(__file__), "**/*.py")) + for rule in rules: + rule = rule.replace(sep, "/") + rule = rule.split("custom_components/hacs")[-1] + rule = f"custom_components/hacs{rule}".replace("/", ".")[:-3] + importlib.import_module(rule) + + +async def async_initialize_rules(): + hass = get_hacs().hass + await hass.async_add_executor_job(_initialize_rules) + + +async def async_run_repository_checks(repository): + hacs = get_hacs() + if not SHARE["rules"]: + await async_initialize_rules() + if not hacs.system.running: + return + checks = [] + for check in SHARE["rules"].get("common", []): + checks.append(check(repository)) + for check in SHARE["rules"].get(repository.data.category, []): + checks.append(check(repository)) + + await asyncio.gather( + *[ + check._async_run_check() + for check in checks or [] + if hacs.system.action or not check.action_only + ] + ) + + total = len([x for x in checks if hacs.system.action or not x.action_only]) + failed = len([x for x in checks if x.failed]) + + if failed != 0: + repository.logger.error("%s %s/%s checks failed", repository, failed, total) + if hacs.system.action: + exit(1) + else: + repository.logger.debug("%s All (%s) checks passed", repository, total) diff --git a/custom_components/hacs/validate/base.py b/custom_components/hacs/validate/base.py new file mode 100644 index 0000000..481201c --- /dev/null +++ b/custom_components/hacs/validate/base.py @@ -0,0 +1,48 @@ +from custom_components.hacs.share import SHARE, get_hacs + + +class ValidationException(Exception): + pass + + +class ValidationBase: + def __init__(self, repository) -> None: + self.repository = repository + self.hacs = get_hacs() + self.failed = False + self.logger = repository.logger + + def __init_subclass__(cls, category="common", **kwargs) -> None: + """Initialize a subclass, register if possible.""" + super().__init_subclass__(**kwargs) + if SHARE["rules"].get(category) is None: + SHARE["rules"][category] = [] + if cls not in SHARE["rules"][category]: + SHARE["rules"][category].append(cls) + + @property + def action_only(self): + return False + + async def _async_run_check(self): + """DO NOT OVERRIDE THIS IN SUBCLASSES!""" + if self.hacs.system.action: + self.logger.info(f"Running check '{self.__class__.__name__}'") + try: + await self.hacs.hass.async_add_executor_job(self.check) + await self.async_check() + except ValidationException as exception: + self.failed = True + self.logger.error(exception) + + def check(self): + pass + + async def async_check(self): + pass + + +class ActionValidationBase(ValidationBase): + @property + def action_only(self): + return True diff --git a/custom_components/hacs/validate/common/hacs_manifest.py b/custom_components/hacs/validate/common/hacs_manifest.py new file mode 100644 index 0000000..6701c12 --- /dev/null +++ b/custom_components/hacs/validate/common/hacs_manifest.py @@ -0,0 +1,10 @@ +from custom_components.hacs.validate.base import ( + ActionValidationBase, + ValidationException, +) + + +class HacsManifest(ActionValidationBase): + def check(self): + if "hacs.json" not in [x.filename for x in self.repository.tree]: + raise ValidationException("The repository has no 'hacs.json' file") diff --git a/custom_components/hacs/validate/common/repository_description.py b/custom_components/hacs/validate/common/repository_description.py new file mode 100644 index 0000000..100de02 --- /dev/null +++ b/custom_components/hacs/validate/common/repository_description.py @@ -0,0 +1,10 @@ +from custom_components.hacs.validate.base import ( + ActionValidationBase, + ValidationException, +) + + +class RepositoryDescription(ActionValidationBase): + def check(self): + if not self.repository.data.description: + raise ValidationException("The repository has no description") diff --git a/custom_components/hacs/validate/common/repository_information_file.py b/custom_components/hacs/validate/common/repository_information_file.py new file mode 100644 index 0000000..0447988 --- /dev/null +++ b/custom_components/hacs/validate/common/repository_information_file.py @@ -0,0 +1,19 @@ +from custom_components.hacs.validate.base import ( + ActionValidationBase, + ValidationException, +) + + +class RepositoryInformationFile(ActionValidationBase): + async def async_check(self): + filenames = [x.filename.lower() for x in self.repository.tree] + if self.repository.data.render_readme and "readme" in filenames: + pass + elif self.repository.data.render_readme and "readme.md" in filenames: + pass + elif "info" in filenames: + pass + elif "info.md" in filenames: + pass + else: + raise ValidationException("The repository has no information file") diff --git a/custom_components/hacs/validate/common/repository_topics.py b/custom_components/hacs/validate/common/repository_topics.py new file mode 100644 index 0000000..194faad --- /dev/null +++ b/custom_components/hacs/validate/common/repository_topics.py @@ -0,0 +1,10 @@ +from custom_components.hacs.validate.base import ( + ActionValidationBase, + ValidationException, +) + + +class RepositoryTopics(ActionValidationBase): + def check(self): + if not self.repository.data.topics: + raise ValidationException("The repository has no topics") diff --git a/custom_components/hacs/validate/integration/integration_manifest.py b/custom_components/hacs/validate/integration/integration_manifest.py new file mode 100644 index 0000000..41b1c97 --- /dev/null +++ b/custom_components/hacs/validate/integration/integration_manifest.py @@ -0,0 +1,10 @@ +from custom_components.hacs.validate.base import ( + ActionValidationBase, + ValidationException, +) + + +class IntegrationManifest(ActionValidationBase, category="integration"): + def check(self): + if "manifest.json" not in [x.filename for x in self.repository.tree]: + raise ValidationException("The repository has no 'hacs.json' file") diff --git a/custom_components/hacs/webresponses/__init__.py b/custom_components/hacs/webresponses/__init__.py new file mode 100644 index 0000000..4b9a8ba --- /dev/null +++ b/custom_components/hacs/webresponses/__init__.py @@ -0,0 +1 @@ +"""Initialize HACS Web responses""" diff --git a/custom_components/hacs/webresponses/frontend.py b/custom_components/hacs/webresponses/frontend.py new file mode 100644 index 0000000..b1a32f9 --- /dev/null +++ b/custom_components/hacs/webresponses/frontend.py @@ -0,0 +1,26 @@ +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from custom_components.hacs.share import get_hacs + + +class HacsFrontendDev(HomeAssistantView): + """Dev View Class for HACS.""" + + requires_auth = False + name = "hacs_files:frontend" + url = r"/hacsfiles/frontend/{requested_file:.+}" + + async def get(self, request, requested_file): # pylint: disable=unused-argument + """Handle HACS Web requests.""" + hacs = get_hacs() + requested = requested_file.split("/")[-1] + request = await hacs.session.get( + f"{hacs.configuration.frontend_repo_url}/{requested}" + ) + if request.status == 200: + result = await request.read() + response = web.Response(body=result) + response.headers["Content-Type"] = "application/javascript" + + return response