updating to latest

This commit is contained in:
root
2021-11-04 01:18:18 -04:00
parent f92b773514
commit 7eadb4c49c
153 changed files with 19015 additions and 5168 deletions

View File

@@ -0,0 +1,68 @@
"""
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/
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
import voluptuous as vol
from .const import DOMAIN, PLATFORMS
from .enums import HacsDisabledReason
from .helpers.functions.configuration_schema import hacs_config_combined
from .operational.setup import (
async_setup as hacs_yaml_setup,
async_setup_entry as hacs_ui_setup,
)
if TYPE_CHECKING:
from .base import HacsBase
CONFIG_SCHEMA = vol.Schema({DOMAIN: hacs_config_combined()}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up this integration using yaml."""
return await hacs_yaml_setup(hass, config)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
config_entry.add_update_listener(async_reload_entry)
return await hacs_ui_setup(hass, config_entry)
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
hacs: HacsBase = hass.data[DOMAIN]
for task in hacs.recuring_tasks:
# Cancel all pending tasks
task()
try:
if hass.data.get("frontend_panels", {}).get("hacs"):
hacs.log.info("Removing sidepanel")
hass.components.frontend.async_remove_panel("hacs")
except AttributeError:
pass
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
hacs.disable_hacs(HacsDisabledReason.REMOVED)
hass.data.pop(DOMAIN, None)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Reload the HACS config entry."""
await async_unload_entry(hass, config_entry)
await async_setup_entry(hass, config_entry)

View File

@@ -0,0 +1 @@
"""Initialize HACS API"""

View File

@@ -0,0 +1,25 @@
"""API Handler for acknowledge_critical_repository"""
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
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))

View File

@@ -0,0 +1,24 @@
"""API Handler for check_local_path"""
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
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))

View File

@@ -0,0 +1,15 @@
"""API Handler for get_critical_repositories"""
from homeassistant.components import websocket_api
import voluptuous as vol
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))

View File

@@ -0,0 +1,28 @@
"""API Handler for hacs_config"""
from homeassistant.components import websocket_api
import voluptuous as vol
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))

View File

@@ -0,0 +1,15 @@
"""API Handler for hacs_removed"""
from homeassistant.components import websocket_api
import voluptuous as vol
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))

View File

@@ -0,0 +1,65 @@
"""API Handler for hacs_repositories"""
from homeassistant.components import websocket_api
import voluptuous as vol
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
and not repo.ignored_by_country_configuration
):
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))

View File

@@ -0,0 +1,113 @@
"""API Handler for hacs_repository"""
from aiogithubapi import AIOGitHubAPIException
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from custom_components.hacs.share import get_hacs
from custom_components.hacs.utils.logger import getLogger
@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(ignore_issues=True, force=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))

View File

@@ -0,0 +1,121 @@
"""API Handler for hacs_repository_data"""
import sys
from aiogithubapi import AIOGitHubAPIException
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from custom_components.hacs.exceptions import HacsException
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
from custom_components.hacs.utils.logger import getLogger
_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"], {}))

View File

@@ -0,0 +1,54 @@
"""API Handler for hacs_settings"""
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from custom_components.hacs.share import get_hacs
from custom_components.hacs.utils.logger import getLogger
_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"], {}))

View File

@@ -0,0 +1,24 @@
"""API Handler for hacs_status"""
from homeassistant.components import websocket_api
import voluptuous as vol
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.core.lovelace_mode,
"reloading_data": hacs.status.reloading_data,
"upgrading_all": hacs.status.upgrading_all,
"disabled": hacs.system.disabled,
"disabled_reason": hacs.system.disabled_reason,
"has_pending_tasks": hacs.queue.has_pending_tasks,
"stage": hacs.stage,
}
connection.send_message(websocket_api.result_message(msg["id"], content))

View File

@@ -0,0 +1,236 @@
"""Base HACS class."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import json
import logging
import math
import pathlib
from typing import TYPE_CHECKING, Any
from aiogithubapi import (
GitHub,
GitHubAPI,
GitHubAuthenticationException,
GitHubRatelimitException,
)
from aiogithubapi.objects.repository import AIOGitHubAPIRepository
from aiohttp.client import ClientSession
from homeassistant.core import HomeAssistant
from homeassistant.loader import Integration
from queueman.manager import QueueManager
from .const import REPOSITORY_HACS_DEFAULT
from .enums import (
ConfigurationType,
HacsCategory,
HacsDisabledReason,
HacsStage,
LovelaceMode,
)
from .exceptions import HacsException
from .utils.decode import decode_content
from .utils.logger import getLogger
if TYPE_CHECKING:
from .hacsbase.data import HacsData
from .helpers.classes.repository import HacsRepository
from .operational.factory import HacsTaskFactory
from .tasks.manager import HacsTaskManager
@dataclass
class HacsConfiguration:
"""HacsConfiguration class."""
appdaemon_path: str = "appdaemon/apps/"
appdaemon: bool = False
config: dict[str, Any] = field(default_factory=dict)
config_entry: dict[str, str] = field(default_factory=dict)
config_type: ConfigurationType | None = None
country: str = "ALL"
debug: bool = False
dev: bool = False
experimental: bool = False
frontend_compact: bool = False
frontend_mode: str = "Grid"
frontend_repo_url: str = ""
frontend_repo: str = ""
netdaemon_path: str = "netdaemon/apps/"
netdaemon: bool = False
onboarding_done: bool = False
plugin_path: str = "www/community/"
python_script_path: str = "python_scripts/"
python_script: bool = False
release_limit: int = 5
sidepanel_icon: str = "hacs:hacs"
sidepanel_title: str = "HACS"
theme_path: str = "themes/"
theme: bool = False
token: str = None
def to_json(self) -> str:
"""Return a json string."""
return asdict(self)
def update_from_dict(self, data: dict) -> None:
"""Set attributes from dicts."""
if not isinstance(data, dict):
raise HacsException("Configuration is not valid.")
for key in data:
self.__setattr__(key, data[key])
@dataclass
class HacsFrontend:
"""HacsFrontend."""
version_running: str | None = None
version_available: str | None = None
version_expected: str | None = None
update_pending: bool = False
@dataclass
class HacsCore:
"""HACS Core info."""
config_path: pathlib.Path | None = None
ha_version: str | None = None
lovelace_mode = LovelaceMode("yaml")
@dataclass
class HacsCommon:
"""Common for HACS."""
categories: set[str] = field(default_factory=set)
default: list[str] = field(default_factory=list)
installed: list[str] = field(default_factory=list)
renamed_repositories: dict[str, str] = field(default_factory=dict)
archived_repositories: list[str] = field(default_factory=list)
skip: list[str] = field(default_factory=list)
@dataclass
class HacsStatus:
"""HacsStatus."""
startup: bool = True
new: bool = False
background_task: bool = False
reloading_data: bool = False
upgrading_all: bool = False
@dataclass
class HacsSystem:
"""HACS System info."""
disabled_reason: HacsDisabledReason | None = None
running: bool = False
stage = HacsStage.SETUP
action: bool = False
@property
def disabled(self) -> bool:
"""Return if HACS is disabled."""
return self.disabled_reason is not None
class HacsBase:
"""Base HACS class."""
_repositories = []
_repositories_by_full_name = {}
_repositories_by_id = {}
common = HacsCommon()
configuration = HacsConfiguration()
core = HacsCore()
data: HacsData | None = None
factory: HacsTaskFactory | None = None
frontend = HacsFrontend()
github: GitHub | None = None
githubapi: GitHubAPI | None = None
hass: HomeAssistant | None = None
integration: Integration | None = None
log: logging.Logger = getLogger()
queue: QueueManager | None = None
recuring_tasks = []
repositories: list[HacsRepository] = []
repository: AIOGitHubAPIRepository | None = None
session: ClientSession | None = None
stage: HacsStage | None = None
status = HacsStatus()
system = HacsSystem()
tasks: HacsTaskManager | None = None
version: str | None = None
@property
def integration_dir(self) -> pathlib.Path:
"""Return the HACS integration dir."""
return self.integration.file_path
async def async_set_stage(self, stage: HacsStage | None) -> None:
"""Set HACS stage."""
if stage and self.stage == stage:
return
self.stage = stage
if stage is not None:
self.log.info("Stage changed: %s", self.stage)
self.hass.bus.async_fire("hacs/stage", {"stage": self.stage})
await self.tasks.async_execute_runtume_tasks()
def disable_hacs(self, reason: HacsDisabledReason) -> None:
"""Disable HACS."""
self.system.disabled_reason = reason
if reason != HacsDisabledReason.REMOVED:
self.log.error("HACS is disabled - %s", reason)
def enable_hacs(self) -> None:
"""Enable HACS."""
self.system.disabled_reason = None
self.log.info("HACS is enabled")
def enable_hacs_category(self, category: HacsCategory):
"""Enable HACS category."""
if category not in self.common.categories:
self.log.info("Enable category: %s", category)
self.common.categories.add(category)
def disable_hacs_category(self, category: HacsCategory):
"""Disable HACS category."""
if category in self.common.categories:
self.log.info("Disabling category: %s", category)
self.common.categories.pop(category)
async def async_can_update(self) -> int:
"""Helper to calculate the number of repositories we can fetch data for."""
try:
response = await self.githubapi.rate_limit()
if ((limit := response.data.resources.core.remaining or 0) - 1000) >= 15:
return math.floor((limit - 1000) / 15)
self.log.error(
"GitHub API ratelimited - %s remaining", response.data.resources.core.remaining
)
self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
except GitHubAuthenticationException as exception:
self.log.error("GitHub authentication failed - %s", exception)
self.disable_hacs(HacsDisabledReason.INVALID_TOKEN)
except GitHubRatelimitException as exception:
self.log.error("GitHub API ratelimited - %s", exception)
self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
except BaseException as exception: # pylint: disable=broad-except
self.log.exception(exception)
return 0
async def async_github_get_hacs_default_file(self, filename: str) -> dict[str, Any]:
"""Get the content of a default file."""
response = await self.githubapi.repos.contents.get(
repository=REPOSITORY_HACS_DEFAULT, path=filename
)
return json.loads(decode_content(response.data.content))

View File

@@ -0,0 +1,157 @@
"""Adds config flow for HACS."""
from aiogithubapi import GitHubDeviceAPI, GitHubException
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 homeassistant.loader import async_get_integration
import voluptuous as vol
from custom_components.hacs.const import CLIENT_ID, DOMAIN, MINIMUM_HA_VERSION
from custom_components.hacs.enums import ConfigurationType
from custom_components.hacs.helpers.functions.configuration_schema import (
RELEASE_LIMIT,
hacs_config_option_schema,
)
from custom_components.hacs.mixin import HacsMixin
class HacsFlowHandler(HacsMixin, 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
self._login_device = 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):
if self._login_device is None or self._login_device.expires_in is None:
async_call_later(self.hass, 1, _wait_for_activation)
return
response = await self.device.activation(device_code=self._login_device.device_code)
self.activation = response.data
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
)
if not self.activation:
integration = await async_get_integration(self.hass, DOMAIN)
if not self.device:
self.device = GitHubDeviceAPI(
client_id=CLIENT_ID,
session=aiohttp_client.async_get_clientsession(self.hass),
**{"client_name": f"HACS/{integration.version}"},
)
async_call_later(self.hass, 1, _wait_for_activation)
try:
response = await self.device.register()
self._login_device = response.data
return self.async_show_progress(
step_id="device",
progress_action="wait_for_device",
description_placeholders={
"url": OAUTH_USER_LOGIN,
"code": self._login_device.user_code,
},
)
except GitHubException as exception:
self.hacs.log.error(exception)
return self.async_abort(reason="github")
return self.async_show_progress_done(next_step_id="device_done")
async def _show_config_form(self, user_input):
"""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(HacsMixin, 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."""
if user_input is not None:
limit = int(user_input.get(RELEASE_LIMIT, 5))
if limit <= 0 or limit > 100:
return self.async_abort(reason="release_limit_value")
return self.async_create_entry(title="", data=user_input)
if self.hacs.configuration is None:
return self.async_abort(reason="not_setup")
if self.hacs.configuration.config_type == ConfigurationType.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))

View File

@@ -0,0 +1,302 @@
"""Constants for HACS"""
from aiogithubapi.common.const import ACCEPT_HEADERS
NAME_LONG = "HACS (Home Assistant Community Store)"
NAME_SHORT = "HACS"
DOMAIN = "hacs"
CLIENT_ID = "395a8e669c5de9f7c6e8"
MINIMUM_HA_VERSION = "2021.2.0"
PROJECT_URL = "https://github.com/hacs/integration/"
ISSUE_URL = f"{PROJECT_URL}issues"
DOMAIN_DATA = f"{NAME_SHORT.lower()}_data"
PACKAGE_NAME = "custom_components.hacs"
REPOSITORY_HACS_DEFAULT = "hacs/default"
REPOSITORY_HACS_INTEGRATION = "hacs/integration"
PLATFORMS = ["sensor"]
HACS_ACTION_GITHUB_API_HEADERS = {
"User-Agent": "HACS/action",
"Accept": ACCEPT_HEADERS["preview"],
}
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."
STARTUP = """
-------------------------------------------------------------------
HACS (Home Assistant Community Store)
Version: {version}
This is a custom integration
If you have any issues with this you need to open an issue here:
https://github.com/hacs/integration/issues
-------------------------------------------------------------------
"""
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",
]

View File

@@ -0,0 +1,57 @@
"""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"
def __str__(self):
return str(self.value)
class ConfigurationType(str, Enum):
YAML = "yaml"
CONFIG_ENTRY = "config_entry"
class LovelaceMode(str, Enum):
"""Lovelace Modes."""
STORAGE = "storage"
AUTO = "auto"
AUTO_GEN = "auto-gen"
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"
class HacsDisabledReason(str, Enum):
RATE_LIMIT = "rate_limit"
REMOVED = "removed"
INVALID_TOKEN = "invalid_token"
CONSTRAINS = "constrains"
LOAD_HACS = "load_hacs"
RESTORE = "restore"

View File

@@ -0,0 +1,21 @@
"""Custom Exceptions for HACS."""
class HacsException(Exception):
"""Super basic."""
class HacsRepositoryArchivedException(HacsException):
"""For repositories that are archived."""
class HacsNotModifiedException(HacsException):
"""For responses that are not modified."""
class HacsExpectedException(HacsException):
"""For stuff that are expected."""
class HacsRepositoryExistException(HacsException):
"""For repositories that are already exist."""

View File

@@ -0,0 +1,208 @@
"""Data handler for HACS."""
import asyncio
import os
from homeassistant.core import callback
from custom_components.hacs.helpers.classes.manifest import HacsManifest
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,
async_save_to_store_default_encoder,
get_store_for_key,
)
from custom_components.hacs.share import get_hacs
from custom_components.hacs.utils.logger import getLogger
def update_repository_from_storage(repository, storage_data):
"""Merge in data from storage into the repo data."""
repository.data.memorize_storage(storage_data)
repository.data.update_data(storage_data)
if repository.data.installed:
return
repository.logger.debug("%s Should be installed but is not... Fixing that!", repository)
repository.data.installed = True
class HacsData:
"""HacsData class."""
def __init__(self):
"""Initialize."""
self.logger = getLogger()
self.hacs = get_hacs()
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,
"archived_repositories": self.hacs.common.archived_repositories,
"renamed_repositories": self.hacs.common.renamed_repositories,
},
)
await self._async_store_content_and_repos()
for event in ("hacs/repository", "hacs/config"):
self.hacs.hass.bus.async_fire(event, {})
async def _async_store_content_and_repos(self): # bb: ignore
"""Store the main repos file and each repo that is out of date."""
# Repositories
self.content = {}
# Not run concurrently since this is bound by disk I/O
for repository in self.hacs.repositories:
await self.async_store_repository_data(repository)
await async_save_to_store(self.hacs.hass, "repositories", self.content)
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,
"etag_repository": repository.data.etag_repository,
"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,
}
self.content[str(repository.data.id)] = data
if (
repository.data.installed
and (repository.data.installed_commit or repository.data.installed_version)
and (export := repository.data.export_data())
):
# export_data will return `None` if the memorized
# data is already up to date which allows us to avoid
# writing data that is already up to date or generating
# executor jobs to check the data on disk to see
# if a write is needed.
await async_save_to_store_default_encoder(
self.hacs.hass,
f"hacs/{repository.data.id}.hacs",
export,
)
repository.data.memorize_storage(export)
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") or {}
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)
self.hacs.common.archived_repositories = hacs.get("archived_repositories", [])
self.hacs.common.renamed_repositories = hacs.get("renamed_repositories", {})
# Repositories
hass = self.hacs.hass
stores = {}
try:
await self.register_unknown_repositories(repositories)
for entry, repo_data in repositories.items():
if self.async_restore_repository(entry, repo_data):
stores[entry] = get_store_for_key(hass, f"hacs/{entry}.hacs")
def _load_from_storage():
for entry, store in stores.items():
if os.path.exists(store.path) and (data := store.load()):
update_repository_from_storage(self.hacs.get_by_id(entry), data)
await hass.async_add_executor_job(_load_from_storage)
self.logger.info("Restore done")
except (Exception, BaseException) as exception: # pylint: disable=broad-except
self.logger.critical(f"[{exception}] Restore Failed!", exc_info=exception)
return False
return True
async def register_unknown_repositories(self, repositories):
"""Registry any unknown repositories."""
register_tasks = [
register_repository(repo_data["full_name"], repo_data["category"], False)
for entry, repo_data in repositories.items()
if not self.hacs.is_known(entry)
]
if register_tasks:
await asyncio.gather(*register_tasks)
@callback
def async_restore_repository(self, entry, repository_data):
full_name = repository_data["full_name"]
if not (repository := self.hacs.get_by_name(full_name)):
self.logger.error(f"Did not find {full_name} ({entry})")
return False
# Restore repository attributes
self.hacs.async_set_repository_id(repository, 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.etag_repository = repository_data.get("etag_repository")
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 = self.hacs.version
repository.data.installed = True
return True

View File

@@ -0,0 +1,338 @@
"""Initialize the HACS base."""
from datetime import timedelta
from aiogithubapi import GitHubException
from aiogithubapi.exceptions import GitHubNotModifiedException
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.store import (
async_load_from_store,
async_save_to_store,
)
from custom_components.hacs.share import (
get_removed,
is_removed,
list_removed_repositories,
)
from ..base import HacsBase
from ..enums import HacsCategory, HacsStage
from ..share import get_factory, get_queue
class Hacs(HacsBase, HacsHelpers):
"""The base class of HACS, nested throughout the project."""
factory = get_factory()
queue = get_queue()
@property
def repositories(self):
"""Return the full repositories list."""
return self._repositories
def async_set_repositories(self, repositories):
"""Set the list of repositories."""
self._repositories = []
self._repositories_by_id = {}
self._repositories_by_full_name = {}
for repository in repositories:
self.async_add_repository(repository)
def async_set_repository_id(self, repository, repo_id):
"""Update a repository id."""
existing_repo_id = str(repository.data.id)
if existing_repo_id == repo_id:
return
if existing_repo_id != "0":
raise ValueError(
f"The repo id for {repository.data.full_name_lower} is already set to {existing_repo_id}"
)
repository.data.id = repo_id
self._repositories_by_id[repo_id] = repository
def async_add_repository(self, repository):
"""Add a repository to the list."""
if repository.data.full_name_lower in self._repositories_by_full_name:
raise ValueError(f"The repo {repository.data.full_name_lower} is already added")
self._repositories.append(repository)
repo_id = str(repository.data.id)
if repo_id != "0":
self._repositories_by_id[repo_id] = repository
self._repositories_by_full_name[repository.data.full_name_lower] = repository
def async_remove_repository(self, repository):
"""Remove a repository from the list."""
if repository.data.full_name_lower not in self._repositories_by_full_name:
return
self._repositories.remove(repository)
repo_id = str(repository.data.id)
if repo_id in self._repositories_by_id:
del self._repositories_by_id[repo_id]
del self._repositories_by_full_name[repository.data.full_name_lower]
def get_by_id(self, repository_id):
"""Get repository by ID."""
return self._repositories_by_id.get(str(repository_id))
def get_by_name(self, repository_full_name):
"""Get repository by full_name."""
if repository_full_name is None:
return None
return self._repositories_by_full_name.get(repository_full_name.lower())
def is_known(self, repository_id):
"""Return a bool if the repository is known."""
return str(repository_id) in self._repositories_by_id
@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
self.hass.bus.async_fire("hacs/status", {})
await self.handle_critical_repositories_startup()
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(hours=2)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.recurring_tasks_all, timedelta(hours=25)
)
)
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.async_github_get_hacs_default_file("critical")
except GitHubNotModifiedException:
return
except GitHubException:
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 self.async_can_update()
self.log.debug(
"Can update %s repositories, items in queue %s",
can_update,
self.queue.pending_tasks,
)
if can_update != 0:
self.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 self.status.startup and repository.data.full_name == "hacs/integration":
continue
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")
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 self.common.renamed_repositories.get(repo):
repo = self.common.renamed_repositories[repo]
if is_removed(repo):
continue
if repo in self.common.archived_repositories:
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))

View File

@@ -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"""

View File

@@ -0,0 +1,47 @@
"""
Manifest handling of a repository.
https://hacs.xyz/docs/publish/start#hacsjson
"""
from typing import List
import attr
from custom_components.hacs.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
if country := manifest.get("country"):
if isinstance(country, str):
manifest["country"] = [country]
for key in manifest:
setattr(manifest_data, key, manifest[key])
return manifest_data

View File

@@ -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)

View File

@@ -0,0 +1,459 @@
"""Repository."""
# pylint: disable=broad-except, no-member
import json
import os
import shutil
import tempfile
import zipfile
from aiogithubapi import AIOGitHubAPIException
from queueman import QueueManager
from custom_components.hacs.exceptions import HacsException, HacsNotModifiedException
from custom_components.hacs.helpers import RepositoryHelpers
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.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
from custom_components.hacs.utils.logger import getLogger
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 ignored_by_country_configuration(self) -> bool:
"""Return True if hidden by country."""
if self.data.installed:
return False
configuration = self.hacs.configuration.country.lower()
manifest = [entry.lower() for entry in self.repository_manifest.country or []]
if configuration == "all":
return False
if not manifest:
return False
return configuration not in manifest
@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:
try:
self.repository_object, etag = await get_repository(
self.hacs.session,
self.hacs.configuration.token,
self.data.full_name,
None if self.data.installed else self.data.etag_repository,
)
self.data.update_data(self.repository_object.attributes)
self.data.etag_repository = etag
except HacsNotModifiedException:
self.logger.debug(
"Did not update %s, content was not modified", self.data.full_name
)
return
# 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, force=False):
"""Common information update steps of the repository."""
self.logger.debug("%s Getting repository information", self)
# Attach repository
current_etag = self.data.etag_repository
await common_update_data(self, ignore_issues, force)
if not self.data.installed and (current_etag == self.data.etag_repository) and not force:
self.logger.debug("Did not update %s, content was not modified", self.data.full_name)
return False
# Update last updated
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)
return True
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
temp_dir = await self.hacs.hass.async_add_executor_job(tempfile.mkdtemp)
temp_file = f"{temp_dir}/{self.data.filename}"
result = await async_save_file(temp_file, filecontent)
with zipfile.ZipFile(temp_file, "r") as zip_file:
zip_file.extractall(self.content.path.local)
def cleanup_temp_dir():
"""Cleanup temp_dir."""
if os.path.exists(temp_dir):
self.logger.debug("Cleaning up %s", temp_dir)
shutil.rmtree(temp_dir)
if result:
self.logger.info("%s Download of %s completed", self, content.name)
await self.hacs.hass.async_add_executor_job(cleanup_temp_dir)
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.async_remove_repository(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."""
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

View File

@@ -0,0 +1,146 @@
"""Repository data."""
from datetime import datetime
import json
from typing import List, Optional
import attr
from homeassistant.helpers.json import JSONEncoder
@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
etag_repository: str = None
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
_storage_data: Optional[dict] = None
@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, filter=lambda attr, _: attr.name != "_storage_data")
def memorize_storage(self, data) -> None:
"""Memorize the storage data."""
self._storage_data = data
def export_data(self) -> Optional[dict]:
"""Export to json if the data has changed.
Returns the data to export if the data needs
to be written.
Returns None if the data has not changed.
"""
export = json.loads(json.dumps(self.to_json(), cls=JSONEncoder))
return None if self._storage_data == export else export
@staticmethod
def create_from_dict(source: dict):
"""Set attributes from dicts."""
data = RepositoryData()
for key in source:
if key not in data.__dict__:
continue
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 not in self.__dict__:
continue
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])

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,234 @@
"""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.exceptions import HacsException
from custom_components.hacs.helpers.functions.filters import (
filter_content_return_one_of_type,
)
from custom_components.hacs.helpers.functions.save import async_save_file
from custom_components.hacs.share import get_hacs
from custom_components.hacs.utils.logger import getLogger
_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(f"Got status code {request.status} when trying to download {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."""
try:
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.")
except (Exception, BaseException) as exception: # pylint: disable=broad-except
repository.validate.errors.append(f"Download was not completed [{exception}]")

View File

@@ -0,0 +1,53 @@
"""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

View File

@@ -0,0 +1,37 @@
"""Helper to get default repositories."""
from typing import List
from aiogithubapi import (
GitHubAuthenticationException,
GitHubNotModifiedException,
GitHubRatelimitException,
)
from custom_components.hacs.const import REPOSITORY_HACS_DEFAULT
from custom_components.hacs.enums import HacsCategory, HacsDisabledReason
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:
repositories = await hacs.async_github_get_hacs_default_file(default)
hacs.log.debug("Got %s elements for %s", len(repositories), default)
except GitHubNotModifiedException:
hacs.log.debug("Content did not change for %s/%s", REPOSITORY_HACS_DEFAULT, default)
except GitHubRatelimitException as exception:
hacs.log.error(exception)
hacs.disable_hacs(HacsDisabledReason.RATE_LIMIT)
except GitHubAuthenticationException as exception:
hacs.log.error(exception)
hacs.disable_hacs(HacsDisabledReason.INVALID_TOKEN)
except BaseException as exception: # pylint: disable=broad-except
hacs.log.error(exception)
return repositories

View File

@@ -0,0 +1,230 @@
"""Return repository information if any."""
import json
from aiogithubapi import AIOGitHubAPIException, AIOGitHubAPINotModifiedException, GitHub
from aiogithubapi.const import ACCEPT_HEADERS
from custom_components.hacs.exceptions import HacsException, HacsNotModifiedException
from custom_components.hacs.helpers.functions.template import render_template
from custom_components.hacs.share import get_hacs
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("<svg", "<disabled").replace("</svg", "</disabled")
return render_template(info, repository)
except (
ValueError,
AIOGitHubAPIException,
Exception, # pylint: disable=broad-except
):
if repository.hacs.system.action:
raise HacsException("::error:: No info file found")
return ""
async def get_repository(session, token, repository_full_name, etag=None):
"""Return a repository object or None."""
hacs = get_hacs()
try:
github = GitHub(
token,
session,
headers={
"User-Agent": f"HACS/{hacs.version}",
"Accept": ACCEPT_HEADERS["preview"],
},
)
repository = await github.get_repo(repository_full_name, etag)
return repository, github.client.last_response.etag
except AIOGitHubAPINotModifiedException as exception:
raise HacsNotModifiedException(exception) from exception
except (ValueError, AIOGitHubAPIException, Exception) as exception:
raise HacsException(exception) from exception
async def get_tree(repository, ref):
"""Return the repository tree."""
try:
tree = await repository.get_tree(ref)
return tree
except (ValueError, AIOGitHubAPIException) as exception:
raise HacsException(exception)
async def get_releases(repository, prerelease=False, returnlimit=5):
"""Return the repository releases."""
try:
releases = await repository.get_releases(prerelease, returnlimit)
return releases
except (ValueError, AIOGitHubAPIException) as exception:
raise HacsException(exception)
def get_frontend_version():
"""get the frontend version from the manifest."""
manifest = read_hacs_manifest()
frontend = 0
for requirement in manifest.get("requirements", []):
if requirement.startswith("hacs_frontend"):
frontend = requirement.split("==")[1]
break
return frontend
def read_hacs_manifest():
"""Reads the HACS manifest file and returns the contents."""
hacs = get_hacs()
content = {}
with open(f"{hacs.core.config_path}/custom_components/hacs/manifest.json") as manifest:
content = json.loads(manifest.read())
return content
async def get_integration_manifest(repository):
"""Return the integration manifest."""
if repository.data.content_in_root:
manifest_path = "manifest.json"
else:
manifest_path = f"{repository.content.path.remote}/manifest.json"
if not manifest_path in [x.full_path for x in repository.tree]:
raise HacsException(f"No file found '{manifest_path}'")
try:
manifest = await repository.repository_object.get_contents(manifest_path, repository.ref)
manifest = json.loads(manifest.content)
except (Exception, BaseException) as exception: # pylint: disable=broad-except
raise HacsException(f"Could not read manifest.json [{exception}]")
try:
repository.integration_manifest = manifest
repository.data.authors = manifest["codeowners"]
repository.data.domain = manifest["domain"]
repository.data.manifest_name = manifest["name"]
repository.data.config_flow = manifest.get("config_flow", False)
if repository.hacs.system.action:
if manifest.get("documentation") is None:
raise HacsException("::error:: manifest.json is missing documentation")
if manifest.get("homeassistant") is not None:
raise HacsException(
"::error:: The homeassistant key in manifest.json is no longer valid"
)
# if manifest.get("issue_tracker") is None:
# raise HacsException("The 'issue_tracker' is missing in manifest.json")
# Set local path
repository.content.path.local = repository.localpath
except KeyError as exception:
raise HacsException(f"Missing expected key {exception} in '{manifest_path}'")
def find_file_name(repository):
"""Get the filename to target."""
if repository.data.category == "plugin":
get_file_name_plugin(repository)
elif repository.data.category == "integration":
get_file_name_integration(repository)
elif repository.data.category == "theme":
get_file_name_theme(repository)
elif repository.data.category == "appdaemon":
get_file_name_appdaemon(repository)
elif repository.data.category == "python_script":
get_file_name_python_script(repository)
if repository.hacs.system.action:
repository.logger.info(f"filename {repository.data.file_name}")
repository.logger.info(f"location {repository.content.path.remote}")
def get_file_name_plugin(repository):
"""Get the filename to target."""
tree = repository.tree
releases = repository.releases.objects
if repository.data.content_in_root:
possible_locations = [""]
else:
possible_locations = ["release", "dist", ""]
# Handler for plug requirement 3
if repository.data.filename:
valid_filenames = [repository.data.filename]
else:
valid_filenames = [
f"{repository.data.name.replace('lovelace-', '')}.js",
f"{repository.data.name}.js",
f"{repository.data.name}.umd.js",
f"{repository.data.name}-bundle.js",
]
for location in possible_locations:
if location == "release":
if not releases:
continue
release = releases[0]
if not release.assets:
continue
asset = release.assets[0]
for filename in valid_filenames:
if filename == asset.name:
repository.data.file_name = filename
repository.content.path.remote = "release"
break
else:
for filename in valid_filenames:
if f"{location+'/' if location else ''}{filename}" in [x.full_path for x in tree]:
repository.data.file_name = filename.split("/")[-1]
repository.content.path.remote = location
break
def get_file_name_integration(repository):
"""Get the filename to target."""
def get_file_name_theme(repository):
"""Get the filename to target."""
tree = repository.tree
for treefile in tree:
if treefile.full_path.startswith(
repository.content.path.remote
) and treefile.full_path.endswith(".yaml"):
repository.data.file_name = treefile.filename
def get_file_name_appdaemon(repository):
"""Get the filename to target."""
def get_file_name_python_script(repository):
"""Get the filename to target."""
tree = repository.tree
for treefile in tree:
if treefile.full_path.startswith(
repository.content.path.remote
) and treefile.full_path.endswith(".py"):
repository.data.file_name = treefile.filename

View File

@@ -0,0 +1,10 @@
"""Helper to check if path is safe to remove."""
from custom_components.hacs.share import get_hacs
from ...utils.path import is_safe
def is_safe_to_remove(path: str) -> bool:
"""Helper to check if path is safe to remove."""
hacs = get_hacs()
return is_safe(hacs, path)

View File

@@ -0,0 +1,35 @@
"""Helper functions: misc"""
import re
from ...utils import version
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()
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 version.version_left_higher_then_right(left, 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()

View File

@@ -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)

View File

@@ -0,0 +1,68 @@
"""Register a repository."""
from __future__ import annotations
from typing import TYPE_CHECKING
from aiogithubapi import AIOGitHubAPIException
from custom_components.hacs.exceptions import (
HacsException,
HacsExpectedException,
HacsRepositoryExistException,
)
from custom_components.hacs.share import get_hacs
from ...repositories import RERPOSITORY_CLASSES
if TYPE_CHECKING:
from ..classes.repository import HacsRepository
# @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: HacsRepository = 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 HacsRepositoryExistException:
return
except AIOGitHubAPIException as exception:
hacs.common.skip.append(repository.data.full_name)
raise HacsException(f"Validation for {full_name} failed with {exception}.") from None
if str(repository.data.id) != "0" and (exists := hacs.get_by_id(repository.data.id)):
hacs.async_remove_repository(exists)
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.async_add_repository(repository)

View File

@@ -0,0 +1,50 @@
"""Download."""
import gzip
import os
import shutil
import aiofiles
from custom_components.hacs.utils.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)

View File

@@ -0,0 +1,79 @@
"""Storage handers."""
# pylint: disable=import-outside-toplevel
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.storage import Store
from homeassistant.util import json as json_util
from custom_components.hacs.const import VERSION_STORAGE
from ...utils.logger import getLogger
_LOGGER = getLogger()
class HACSStore(Store):
"""A subclass of Store that allows multiple loads in the executor."""
def load(self):
"""Load the data from disk if version matches."""
data = json_util.load_json(self.path)
if data == {} or data["version"] != self.version:
return None
return data["data"]
def get_store_key(key):
"""Return the key to use with homeassistant.helpers.storage.Storage."""
return key if "/" in key else f"hacs.{key}"
def _get_store_for_key(hass, key, encoder):
"""Create a Store object for the key."""
return HACSStore(hass, VERSION_STORAGE, get_store_key(key), encoder=encoder)
def get_store_for_key(hass, key):
"""Create a Store object for the key."""
return _get_store_for_key(hass, key, JSONEncoder)
async def async_load_from_store(hass, key):
"""Load the retained data from store and return de-serialized data."""
return await get_store_for_key(hass, key).async_load() or {}
async def async_save_to_store_default_encoder(hass, key, data):
"""Generate store json safe data to the filesystem.
The data is expected to be encodable with the default
python json encoder. It should have already been passed through
JSONEncoder if needed.
"""
await _get_store_for_key(hass, key, None).async_save(data)
async def async_save_to_store(hass, key, data):
"""Generate dynamic data to store and save it to the filesystem.
The data is only written if the content on the disk has changed
by reading the existing content and comparing it.
If the data has changed this will generate two executor jobs
If the data has not changed this will generate one executor job
"""
current = await async_load_from_store(hass, key)
if current is None or current != data:
await get_store_for_key(hass, key).async_save(data)
return
_LOGGER.debug(
"Did not store data for '%s'. Content did not change",
get_store_key(key),
)
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()

View File

@@ -0,0 +1,32 @@
"""Custom template support."""
# pylint: disable=broad-except
from jinja2 import Template
from custom_components.hacs.utils.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

View File

@@ -0,0 +1,124 @@
"""Helper to do common validation for repositories."""
from __future__ import annotations
from typing import TYPE_CHECKING
from aiogithubapi import AIOGitHubAPIException
from custom_components.hacs.exceptions import (
HacsException,
HacsNotModifiedException,
HacsRepositoryArchivedException,
HacsRepositoryExistException,
)
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
if TYPE_CHECKING:
from custom_components.hacs.helpers.classes.repository import HacsRepository
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: HacsRepository, ignore_issues=False, force=False):
"""Common update data."""
hacs = get_hacs()
releases = []
try:
repository_object, etag = await get_repository(
hacs.session,
hacs.configuration.token,
repository.data.full_name,
etag=None if force or repository.data.installed else repository.data.etag_repository,
)
repository.repository_object = repository_object
if repository.data.full_name.lower() != repository_object.full_name.lower():
hacs.common.renamed_repositories[
repository.data.full_name
] = repository_object.full_name
if str(repository_object.id) not in hacs.common.default:
hacs.common.default.append(str(repository_object.id))
raise HacsRepositoryExistException
repository.data.update_data(repository_object.attributes)
repository.data.etag_repository = etag
except HacsNotModifiedException:
return
except HacsRepositoryExistException:
raise HacsRepositoryExistException from None
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.")
hacs.common.archived_repositories.append(repository.data.full_name)
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

View File

@@ -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

View File

@@ -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"""

View File

@@ -0,0 +1,113 @@
# pylint: disable=missing-class-docstring,missing-module-docstring,missing-function-docstring,no-member
from abc import ABC
import os
import tempfile
from custom_components.hacs.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.validate)
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

View File

@@ -0,0 +1,41 @@
# 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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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.core.ha_version, self.data.homeassistant
):
return False
return True
@property
def can_install(self):
"""kept for legacy compatibility"""
return self.can_be_installed

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,29 @@
const hacsIcons = {
hacs: {
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",
keywords: ["hacs", "home assistant community store"],
},
};
window.customIcons = window.customIcons || {};
window.customIconsets = window.customIconsets || {};
// For Home Assistant > 2021.11
window.customIcons["hacs"] = {
getIcon: async (iconName) => (
{ path: hacsIcons[iconName]?.path }
),
getIconList: async () =>
Object.entries(hacsIcons).map(([icon, content]) => ({
name: icon,
keywords: content.keywords,
})
)
};
// For Home Assistant < 2021.11
window.customIconsets["hacs"] = async () => {
return {
path: hacsIcons.hacs.path
};
};

View File

@@ -0,0 +1,27 @@
{
"codeowners": [
"@ludeeus"
],
"config_flow": true,
"dependencies": [
"http",
"websocket_api",
"frontend",
"persistent_notification",
"lovelace"
],
"documentation": "https://hacs.xyz/docs/configuration/start",
"domain": "hacs",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/hacs/integration/issues",
"name": "HACS",
"requirements": [
"aiofiles>=0.6.0",
"aiogithubapi>=21.8.1",
"awesomeversion>=21.2.2",
"backoff>=1.10.0",
"hacs_frontend==20211010111104",
"queueman==0.5"
],
"version": "1.16.0"
}

View File

@@ -0,0 +1,24 @@
"""Mixin classes."""
# pylint: disable=too-few-public-methods
from __future__ import annotations
from logging import Logger
from typing import TYPE_CHECKING
from .share import get_hacs
from .utils.logger import getLogger
if TYPE_CHECKING:
from .hacsbase.hacs import Hacs
class HacsMixin:
"""Mixin to provide 'self.hacs' to classes."""
hacs: Hacs = get_hacs()
class LogMixin:
"""Mixin to provide 'self.log' to classes."""
log: Logger = getLogger()

View File

@@ -0,0 +1,120 @@
"""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.utils.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)

View File

@@ -0,0 +1,61 @@
# pylint: disable=missing-docstring,invalid-name
import asyncio
from aiogithubapi import AIOGitHubAPIException
from custom_components.hacs.exceptions import (
HacsException,
HacsNotModifiedException,
HacsRepositoryArchivedException,
)
from custom_components.hacs.helpers.functions.register_repository import (
register_repository,
)
from custom_components.hacs.utils.logger import getLogger
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 HacsNotModifiedException:
pass
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 HacsNotModifiedException:
pass
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)

View File

@@ -0,0 +1,173 @@
"""Setup HACS."""
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
from aiogithubapi.const import ACCEPT_HEADERS
from homeassistant.components.lovelace.system_health import system_health_info
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, __version__ as HAVERSION
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.loader import async_get_integration
from custom_components.hacs.const import DOMAIN, STARTUP
from custom_components.hacs.enums import (
ConfigurationType,
HacsStage,
LovelaceMode,
)
from custom_components.hacs.hacsbase.data import HacsData
from custom_components.hacs.share import get_hacs
from custom_components.hacs.tasks.manager import HacsTaskManager
async def _async_common_setup(hass: HomeAssistant):
"""Common setup stages."""
integration = await async_get_integration(hass, DOMAIN)
hacs = get_hacs()
hacs.enable_hacs()
await hacs.async_set_stage(None)
hacs.log.info(STARTUP.format(version=integration.version))
hacs.integration = integration
hacs.version = integration.version
hacs.hass = hass
hacs.data = HacsData()
hacs.system.running = True
hacs.session = async_create_clientsession(hass)
hacs.tasks = HacsTaskManager(hacs=hacs, hass=hass)
hacs.core.lovelace_mode = LovelaceMode.YAML
try:
lovelace_info = await system_health_info(hacs.hass)
hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml"))
except Exception: # pylint: disable=broad-except
# If this happens, the users YAML is not valid, we assume YAML mode
pass
hacs.log.debug(f"Configuration type: {hacs.configuration.config_type}")
hacs.core.config_path = hacs.hass.config.path()
hacs.core.ha_version = HAVERSION
await hacs.tasks.async_load()
# Setup session for API clients
session = async_create_clientsession(hacs.hass)
## Legacy GitHub client
hacs.github = GitHub(
hacs.configuration.token,
session,
headers={
"User-Agent": f"HACS/{hacs.version}",
"Accept": ACCEPT_HEADERS["preview"],
},
)
## New GitHub client
hacs.githubapi = GitHubAPI(
token=hacs.configuration.token,
session=session,
**{"client_name": f"HACS/{hacs.version}"},
)
hass.data[DOMAIN] = hacs
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
hacs = get_hacs()
if config_entry.source == SOURCE_IMPORT:
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
return False
if hass.data.get(DOMAIN) is not None:
return False
hacs.configuration.update_from_dict(
{
"config_entry": config_entry,
"config_type": ConfigurationType.CONFIG_ENTRY,
**config_entry.data,
**config_entry.options,
}
)
await _async_common_setup(hass)
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.config_type == ConfigurationType.CONFIG_ENTRY:
return True
hacs.configuration.update_from_dict(
{
"config_type": ConfigurationType.YAML,
**config[DOMAIN],
"config": config[DOMAIN],
}
)
await _async_common_setup(hass)
await async_startup_wrapper_for_yaml()
return True
async def async_startup_wrapper_for_config_entry():
"""Startup wrapper for ui config."""
hacs = get_hacs()
try:
startup_result = await async_hacs_startup()
except AIOGitHubAPIException:
startup_result = False
if not startup_result:
raise ConfigEntryNotReady(hacs.system.disabled_reason)
hacs.enable_hacs()
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.log.info("Could not setup HACS, trying again in 15 min")
async_call_later(hacs.hass, 900, async_startup_wrapper_for_yaml)
return
hacs.enable_hacs()
async def async_hacs_startup():
"""HACS startup tasks."""
hacs = get_hacs()
await hacs.async_set_stage(HacsStage.SETUP)
if hacs.system.disabled:
return False
await hacs.async_set_stage(HacsStage.STARTUP)
if hacs.system.disabled:
return False
# Setup startup tasks
if hacs.hass.state == CoreState.running:
async_call_later(hacs.hass, 5, hacs.startup_tasks)
else:
hacs.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, hacs.startup_tasks)
# Mischief managed!
await hacs.async_set_stage(HacsStage.WAITING)
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
return not hacs.system.disabled

View File

@@ -0,0 +1,16 @@
"""Initialize repositories."""
from custom_components.hacs.repositories.appdaemon import HacsAppdaemonRepository
from custom_components.hacs.repositories.integration import HacsIntegrationRepository
from custom_components.hacs.repositories.netdaemon import HacsNetdaemonRepository
from custom_components.hacs.repositories.plugin import HacsPluginRepository
from custom_components.hacs.repositories.python_script import HacsPythonScriptRepository
from custom_components.hacs.repositories.theme import HacsThemeRepository
RERPOSITORY_CLASSES = {
"theme": HacsThemeRepository,
"integration": HacsIntegrationRepository,
"python_script": HacsPythonScriptRepository,
"appdaemon": HacsAppdaemonRepository,
"netdaemon": HacsNetdaemonRepository,
"plugin": HacsPluginRepository,
}

View File

@@ -0,0 +1,71 @@
"""Class for appdaemon apps in HACS."""
from aiogithubapi import AIOGitHubAPIException
from custom_components.hacs.enums import HacsCategory
from custom_components.hacs.exceptions import HacsException
from custom_components.hacs.helpers.classes.repository import HacsRepository
class HacsAppdaemonRepository(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, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force):
return
# 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

View File

@@ -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.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,
)
class HacsIntegrationRepository(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, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force):
return
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")

View File

@@ -0,0 +1,81 @@
"""Class for netdaemon apps in HACS."""
from custom_components.hacs.enums import HacsCategory
from custom_components.hacs.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,
)
class HacsNetdaemonRepository(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, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force):
return
# 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

View File

@@ -0,0 +1,75 @@
"""Class for plugins in HACS."""
import json
from custom_components.hacs.exceptions import HacsException
from custom_components.hacs.helpers.classes.repository import HacsRepository
from custom_components.hacs.helpers.functions.information import find_file_name
class HacsPluginRepository(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, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force):
return
# 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

View File

@@ -0,0 +1,79 @@
"""Class for python_scripts in HACS."""
from custom_components.hacs.enums import HacsCategory
from custom_components.hacs.exceptions import HacsException
from custom_components.hacs.helpers.classes.repository import HacsRepository
from custom_components.hacs.helpers.functions.information import find_file_name
class HacsPythonScriptRepository(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, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force):
return
# 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)

View File

@@ -0,0 +1,76 @@
"""Class for themes in HACS."""
from custom_components.hacs.enums import HacsCategory
from custom_components.hacs.exceptions import HacsException
from custom_components.hacs.helpers.classes.repository import HacsRepository
from custom_components.hacs.helpers.functions.information import find_file_name
class HacsThemeRepository(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, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force):
return
# 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

View File

@@ -0,0 +1,118 @@
"""Sensor platform for HACS."""
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from custom_components.hacs.const import DOMAIN, NAME_SHORT
from custom_components.hacs.mixin import HacsMixin
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(HacsMixin, 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": str(self.hacs.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."""
if self.hacs.status.background_task:
return
self.repositories = []
for repository in self.hacs.repositories:
if (
repository.pending_upgrade
and repository.data.category in self.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)
)

View File

@@ -0,0 +1,66 @@
"""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"]

View File

@@ -0,0 +1,36 @@
"""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."""
hacs: HacsBase = hass.data[DOMAIN]
response = await hacs.githubapi.rate_limit()
data = {
"GitHub API": system_health.async_check_can_reach_url(hass, BASE_API_URL, GITHUB_STATUS),
"Github API Calls Remaining": response.data.resources.core.remaining,
"Installed Version": hacs.version,
"Stage": hacs.stage,
"Available Repositories": len(hacs.repositories),
"Installed Repositories": len([repo for repo in hacs.repositories if repo.data.installed]),
}
if hacs.system.disabled:
data["Disabled"] = hacs.system.disabled_reason
return data

View File

@@ -0,0 +1 @@
"""Init HACS tasks."""

View File

@@ -0,0 +1,35 @@
"""Starting setup task: extra stores."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..enums import HacsCategory, HacsStage
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Set up extra stores in HACS if enabled in Home Assistant."""
stages = [HacsStage.SETUP]
async def async_execute(self) -> None:
self.hacs.common.categories = set()
for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN):
self.hacs.enable_hacs_category(HacsCategory(category))
if HacsCategory.PYTHON_SCRIPT in self.hacs.hass.config.components:
self.hacs.enable_hacs_category(HacsCategory.PYTHON_SCRIPT)
if self.hacs.hass.services.has_service("frontend", "reload_themes"):
self.hacs.enable_hacs_category(HacsCategory.THEME)
if self.hacs.configuration.appdaemon:
self.hacs.enable_hacs_category(HacsCategory.APPDAEMON)
if self.hacs.configuration.netdaemon:
self.hacs.enable_hacs_category(HacsCategory.NETDAEMON)

View File

@@ -0,0 +1,58 @@
""""Hacs base setup task."""
# pylint: disable=abstract-method
from __future__ import annotations
from datetime import timedelta
from timeit import default_timer as timer
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..enums import HacsStage
from ..mixin import LogMixin
class HacsTask(LogMixin):
"""Hacs task base."""
hass: HomeAssistant
events: list[str] | None = None
schedule: timedelta | None = None
stages: list[HacsStage] | None = None
def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None:
self.hacs = hacs
self.hass = hass
@property
def slug(self) -> str:
"""Return the check slug."""
return self.__class__.__module__.rsplit(".", maxsplit=1)[-1]
async def execute_task(self, *_, **__) -> None:
"""Execute the task defined in subclass."""
if self.hacs.system.disabled:
self.log.warning(
"Skipping task %s, HACS is disabled - %s",
self.slug,
self.hacs.system.disabled_reason,
)
return
self.log.info("Executing task: %s", self.slug)
start_time = timer()
try:
if task := getattr(self, "execute", None):
await self.hass.async_add_executor_job(task)
elif task := getattr(self, "async_execute", None):
await task() # pylint: disable=not-callable
except BaseException as exception: # pylint: disable=broad-except
self.log.error("Task %s failed: %s", self.slug, exception)
else:
self.log.debug(
"Task %s took " "%.2f seconds to complete",
self.slug,
timer() - start_time,
)

View File

@@ -0,0 +1,43 @@
""""Starting setup task: Constrains"."""
from __future__ import annotations
import os
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..const import MINIMUM_HA_VERSION
from ..enums import HacsDisabledReason, HacsStage
from ..utils.version import version_left_higher_then_right
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Check env Constrains."""
stages = [HacsStage.SETUP]
def execute(self) -> None:
for location in (
self.hass.config.path("custom_components/custom_updater.py"),
self.hass.config.path("custom_components/custom_updater/__init__.py"),
):
if os.path.exists(location):
self.log.critical(
"This cannot be used with custom_updater. "
"To use this you need to remove custom_updater form %s",
location,
)
self.hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
if not version_left_higher_then_right(self.hacs.core.ha_version, MINIMUM_HA_VERSION):
self.log.critical(
"You need HA version %s or newer to use this integration.",
MINIMUM_HA_VERSION,
)
self.hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)

View File

@@ -0,0 +1,28 @@
"""Starting setup task: clear storage."""
from __future__ import annotations
import os
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..enums import HacsStage
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Clear old files from storage."""
stages = [HacsStage.SETUP]
def execute(self) -> None:
for storage_file in ("hacs",):
path = f"{self.hacs.core.config_path}/.storage/{storage_file}"
if os.path.isfile(path):
self.log.info("Cleaning up old storage file: %s", path)
os.remove(path)

View File

@@ -0,0 +1,23 @@
""""Hacs base setup task."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
""" "Hacs task base."""
schedule = timedelta(weeks=52)
def execute(self) -> None:
self.log.debug("Hello World!")

View File

@@ -0,0 +1,40 @@
"""Starting setup task: load HACS repository."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..enums import HacsDisabledReason, HacsStage
from ..exceptions import HacsException
from ..helpers.functions.register_repository import register_repository
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Load HACS repositroy."""
stages = [HacsStage.STARTUP]
async def async_execute(self) -> None:
try:
repository = self.hacs.get_by_name("hacs/integration")
if repository is None:
await register_repository("hacs/integration", "integration")
repository = self.hacs.get_by_name("hacs/integration")
if repository is None:
raise HacsException("Unknown error")
repository.data.installed = True
repository.data.installed_version = self.hacs.integration.version
repository.data.new = False
self.hacs.repository = repository.repository_object
except HacsException as exception:
if "403" in f"{exception}":
self.log.critical("GitHub API is ratelimited, or the token is wrong.")
else:
self.log.critical("[%s] - Could not load HACS!", exception)
self.hacs.disable_hacs(HacsDisabledReason.LOAD_HACS)

View File

@@ -0,0 +1,75 @@
"""Hacs task manager."""
from __future__ import annotations
import asyncio
from importlib import import_module
from pathlib import Path
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..mixin import LogMixin
from .base import HacsTask
class HacsTaskManager(LogMixin):
"""Hacs task manager."""
def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None:
"""Initialize the setup manager class."""
self.hacs = hacs
self.hass = hass
self.__tasks: dict[str, HacsTask] = {}
@property
def tasks(self) -> list[HacsTask]:
"""Return all list of all tasks."""
return list(self.__tasks.values())
async def async_load(self) -> None:
"""Load all tasks."""
task_files = Path(__file__).parent
task_modules = (
module.stem
for module in task_files.glob("*.py")
if module.name not in ("base.py", "__init__.py", "manager.py")
)
async def _load_module(module: str):
task_module = import_module(f"{__package__}.{module}")
if task := await task_module.async_setup_task(hacs=self.hacs, hass=self.hass):
self.__tasks[task.slug] = task
await asyncio.gather(*[_load_module(task) for task in task_modules])
self.log.info("Loaded %s tasks", len(self.tasks))
schedule_tasks = len(self.hacs.recuring_tasks) == 0
for task in self.tasks:
if task.events is not None:
for event in task.events:
self.hass.bus.async_listen_once(event, task.execute_task)
if task.schedule is not None and schedule_tasks:
self.log.debug("Scheduling the %s task to run every %s", task.slug, task.schedule)
self.hacs.recuring_tasks.append(
self.hacs.hass.helpers.event.async_track_time_interval(
task.execute_task, task.schedule
)
)
def get(self, slug: str) -> HacsTask | None:
"""Return a task."""
return self.__tasks.get(slug)
async def async_execute_runtume_tasks(self) -> None:
"""Execute the the execute methods of each runtime task if the stage matches."""
self.hacs.status.background_task = True
await asyncio.gather(
*(
task.execute_task()
for task in self.tasks
if task.stages is not None and self.hacs.stage in task.stages
)
)
self.hacs.status.background_task = False

View File

@@ -0,0 +1,23 @@
""""Starting setup task: Restore"."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..enums import HacsDisabledReason, HacsStage
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Restore HACS data."""
stages = [HacsStage.SETUP]
async def async_execute(self) -> None:
if not await self.hacs.data.restore():
self.hacs.disable_hacs(HacsDisabledReason.RESTORE)

View File

@@ -0,0 +1,87 @@
""""Starting setup task: Frontend"."""
from __future__ import annotations
from hacs_frontend import locate_dir
from hacs_frontend.version import VERSION as FE_VERSION
from ..const import DOMAIN
from ..enums import HacsStage
from ..webresponses.frontend import HacsFrontendDev
from .base import HacsTask
URL_BASE = "/hacsfiles"
from homeassistant.core import HomeAssistant
from ..base import HacsBase
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Setup the HACS frontend."""
stages = [HacsStage.SETUP]
async def async_execute(self) -> None:
# Register themes
self.hass.http.register_static_path(f"{URL_BASE}/themes", self.hass.config.path("themes"))
# Register frontend
if self.hacs.configuration.frontend_repo_url:
self.log.warning("Frontend development mode enabled. Do not run in production!")
self.hass.http.register_view(HacsFrontendDev())
else:
#
self.hass.http.register_static_path(
f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
)
# Custom iconset
self.hass.http.register_static_path(
f"{URL_BASE}/iconset.js", str(self.hacs.integration_dir / "iconset.js")
)
if "frontend_extra_module_url" not in self.hass.data:
self.hass.data["frontend_extra_module_url"] = set()
self.hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js")
# Register www/community for all other files
use_cache = self.hacs.core.lovelace_mode == "storage"
self.log.info(
"%s mode, cache for /hacsfiles/: %s",
self.hacs.core.lovelace_mode,
use_cache,
)
self.hass.http.register_static_path(
URL_BASE,
self.hass.config.path("www/community"),
cache_headers=use_cache,
)
self.hacs.frontend.version_running = FE_VERSION
for requirement in self.hacs.integration.requirements:
if "hacs_frontend" in requirement:
self.hacs.frontend.version_expected = requirement.split("==")[-1]
# Add to sidepanel if needed
if DOMAIN not in self.hass.data.get("frontend_panels", {}):
self.hass.components.frontend.async_register_built_in_panel(
component_name="custom",
sidebar_title=self.hacs.configuration.sidepanel_title,
sidebar_icon=self.hacs.configuration.sidepanel_icon,
frontend_url_path=DOMAIN,
config={
"_panel_custom": {
"name": "hacs-frontend",
"embed_iframe": True,
"trust_external": False,
"js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={FE_VERSION}",
}
},
require_admin=True,
)

View File

@@ -0,0 +1,31 @@
""""Starting setup task: Sensor"."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers.discovery import async_load_platform
from ..base import HacsBase
from ..const import DOMAIN, PLATFORMS
from ..enums import ConfigurationType, HacsStage
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Setup the HACS sensor platform."""
stages = [HacsStage.SETUP]
async def async_execute(self) -> None:
if self.hacs.configuration.config_type == ConfigurationType.YAML:
self.hass.async_create_task(
async_load_platform(self.hass, "sensor", DOMAIN, {}, self.hacs.configuration.config)
)
else:
self.hass.config_entries.async_setup_platforms(
self.hacs.configuration.config_entry, PLATFORMS
)

View File

@@ -0,0 +1,42 @@
"""Register WS API endpoints for HACS."""
from __future__ import annotations
from homeassistant.components.websocket_api import async_register_command
from homeassistant.core import HomeAssistant
from ..api.acknowledge_critical_repository import acknowledge_critical_repository
from ..api.check_local_path import check_local_path
from ..api.get_critical_repositories import get_critical_repositories
from ..api.hacs_config import hacs_config
from ..api.hacs_removed import hacs_removed
from ..api.hacs_repositories import hacs_repositories
from ..api.hacs_repository import hacs_repository
from ..api.hacs_repository_data import hacs_repository_data
from ..api.hacs_settings import hacs_settings
from ..api.hacs_status import hacs_status
from ..base import HacsBase
from ..enums import HacsStage
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Setup the HACS websocket API."""
stages = [HacsStage.SETUP]
async def async_execute(self) -> None:
async_register_command(self.hass, hacs_settings)
async_register_command(self.hass, hacs_config)
async_register_command(self.hass, hacs_repositories)
async_register_command(self.hass, hacs_repository)
async_register_command(self.hass, hacs_repository_data)
async_register_command(self.hass, check_local_path)
async_register_command(self.hass, hacs_status)
async_register_command(self.hass, hacs_removed)
async_register_command(self.hass, acknowledge_critical_repository)
async_register_command(self.hass, get_critical_repositories)

View File

@@ -0,0 +1,22 @@
""""Store HACS data."""
from __future__ import annotations
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
""" "Hacs task base."""
events = [EVENT_HOMEASSISTANT_FINAL_WRITE]
async def async_execute(self) -> None:
await self.hacs.data.async_write()

View File

@@ -0,0 +1,23 @@
""""Starting setup task: Verify API"."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..enums import HacsStage
from .base import HacsTask
async def async_setup_task(hacs: HacsBase, hass: HomeAssistant) -> Task:
"""Set up this task."""
return Task(hacs=hacs, hass=hass)
class Task(HacsTask):
"""Verify the connection to the GitHub API."""
stages = [HacsStage.SETUP]
async def async_execute(self) -> None:
can_update = await self.hacs.async_can_update()
self.log.debug("Can update %s repositories", can_update)

View File

@@ -0,0 +1,53 @@
{
"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.",
"not_setup": "HACS is not setup."
},
"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": {
"abort": {
"not_setup": "HACS is not setup.",
"release_limit_value": "The release limit needs to be between 1 and 100"
},
"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"
}
}
}
}
}

View File

@@ -0,0 +1 @@
"""Initialize HACS utils."""

View File

@@ -0,0 +1,7 @@
"""Util to decode content from the github API."""
from base64 import b64decode
def decode_content(content: str) -> str:
"""Decode content."""
return b64decode(bytearray(content, "utf-8")).decode()

View File

@@ -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

View File

@@ -0,0 +1,21 @@
"""Path utils"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..hacsbase.hacs import Hacs
def is_safe(hacs: Hacs, path: str | Path) -> bool:
"""Helper to check if path is safe to remove."""
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/"),
]
return Path(path) not in paths

View File

@@ -0,0 +1,15 @@
"""Version utils."""
from functools import lru_cache
from awesomeversion import AwesomeVersion, AwesomeVersionException
@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."""
try:
return AwesomeVersion(left) >= AwesomeVersion(right)
except AwesomeVersionException:
return False

View File

@@ -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")
```

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -0,0 +1 @@
"""Initialize HACS Web responses"""

View File

@@ -0,0 +1,24 @@
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