Bump HACS to 1.30.1

This commit is contained in:
root
2023-01-30 16:34:17 -08:00
parent 7c560d76e4
commit b873ba0ef0
15 changed files with 456 additions and 142 deletions

View File

@@ -25,6 +25,7 @@ import voluptuous as vol
from .base import HacsBase from .base import HacsBase
from .const import DOMAIN, MINIMUM_HA_VERSION, STARTUP from .const import DOMAIN, MINIMUM_HA_VERSION, STARTUP
from .data_client import HacsDataClient
from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode
from .frontend import async_register_frontend from .frontend import async_register_frontend
from .utils.configuration_schema import hacs_config_combined from .utils.configuration_schema import hacs_config_combined
@@ -87,6 +88,10 @@ async def async_initialize_integration(
hacs.hass = hass hacs.hass = hass
hacs.queue = QueueManager(hass=hass) hacs.queue = QueueManager(hass=hass)
hacs.data = HacsData(hacs=hacs) hacs.data = HacsData(hacs=hacs)
hacs.data_client = HacsDataClient(
session=clientsession,
client_name=f"HACS/{integration.version}",
)
hacs.system.running = True hacs.system.running = True
hacs.session = clientsession hacs.session = clientsession
@@ -153,6 +158,7 @@ async def async_initialize_integration(
hacs.disable_hacs(HacsDisabledReason.RESTORE) hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False return False
if not hacs.configuration.experimental:
can_update = await hacs.async_can_update() can_update = await hacs.async_can_update()
hacs.log.debug("Can update %s repositories", can_update) hacs.log.debug("Can update %s repositories", can_update)
@@ -168,7 +174,7 @@ async def async_initialize_integration(
hacs.log.info("Update entities are only supported when using UI configuration") hacs.log.info("Update entities are only supported when using UI configuration")
else: else:
hass.config_entries.async_setup_platforms( await hass.config_entries.async_forward_entry_setups(
config_entry, config_entry,
[Platform.SENSOR, Platform.UPDATE] [Platform.SENSOR, Platform.UPDATE]
if hacs.configuration.experimental if hacs.configuration.experimental

View File

@@ -28,11 +28,17 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.issue_registry import async_create_issue, IssueSeverity from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.loader import Integration from homeassistant.loader import Integration
from homeassistant.util import dt from homeassistant.util import dt
from .const import DOMAIN, TV from custom_components.hacs.repositories.base import (
HACS_MANIFEST_KEYS_TO_EXPORT,
REPOSITORY_KEYS_TO_EXPORT,
)
from .const import DOMAIN, TV, URL_BASE
from .data_client import HacsDataClient
from .enums import ( from .enums import (
ConfigurationType, ConfigurationType,
HacsCategory, HacsCategory,
@@ -47,6 +53,7 @@ from .exceptions import (
HacsException, HacsException,
HacsExecutionStillInProgress, HacsExecutionStillInProgress,
HacsExpectedException, HacsExpectedException,
HacsNotModifiedException,
HacsRepositoryArchivedException, HacsRepositoryArchivedException,
HacsRepositoryExistException, HacsRepositoryExistException,
HomeAssistantCoreRepositoryException, HomeAssistantCoreRepositoryException,
@@ -164,6 +171,9 @@ class HacsStatus:
startup: bool = True startup: bool = True
new: bool = False new: bool = False
active_frontend_endpoint_plugin: bool = False
active_frontend_endpoint_theme: bool = False
inital_fetch_done: bool = False
@dataclass @dataclass
@@ -174,6 +184,7 @@ class HacsSystem:
running: bool = False running: bool = False
stage = HacsStage.SETUP stage = HacsStage.SETUP
action: bool = False action: bool = False
generator: bool = False
@property @property
def disabled(self) -> bool: def disabled(self) -> bool:
@@ -263,7 +274,7 @@ class HacsRepositories:
self._default_repositories.add(repo_id) self._default_repositories.add(repo_id)
def set_repository_id(self, repository, repo_id): def set_repository_id(self, repository: HacsRepository, repo_id: str):
"""Update a repository id.""" """Update a repository id."""
existing_repo_id = str(repository.data.id) existing_repo_id = str(repository.data.id)
if existing_repo_id == repo_id: if existing_repo_id == repo_id:
@@ -348,6 +359,7 @@ class HacsBase:
configuration = HacsConfiguration() configuration = HacsConfiguration()
core = HacsCore() core = HacsCore()
data: HacsData | None = None data: HacsData | None = None
data_client: HacsDataClient | None = None
frontend_version: str | None = None frontend_version: str | None = None
github: GitHub | None = None github: GitHub | None = None
githubapi: GitHubAPI | None = None githubapi: GitHubAPI | None = None
@@ -544,8 +556,6 @@ class HacsBase:
if check: if check:
try: try:
await repository.async_registration(ref) await repository.async_registration(ref)
if self.status.new:
repository.data.new = False
if repository.validate.errors: if repository.validate.errors:
self.common.skip.append(repository.data.full_name) self.common.skip.append(repository.data.full_name)
if not self.status.startup: if not self.status.startup:
@@ -559,7 +569,11 @@ class HacsBase:
repository.logger.info("%s Validation completed", repository.string) repository.logger.info("%s Validation completed", repository.string)
else: else:
repository.logger.info("%s Registration completed", repository.string) repository.logger.info("%s Registration completed", repository.string)
except (HacsRepositoryExistException, HacsRepositoryArchivedException): except (HacsRepositoryExistException, HacsRepositoryArchivedException) as exception:
if self.system.generator:
repository.logger.error(
"%s Registration Failed - %s", repository.string, exception
)
return return
except AIOGitHubAPIException as exception: except AIOGitHubAPIException as exception:
self.common.skip.append(repository.data.full_name) self.common.skip.append(repository.data.full_name)
@@ -567,6 +581,9 @@ class HacsBase:
f"Validation for {repository_full_name} failed with {exception}." f"Validation for {repository_full_name} failed with {exception}."
) from exception ) from exception
if self.status.new:
repository.data.new = False
if repository_id is not None: if repository_id is not None:
repository.data.id = repository_id repository.data.id = repository_id
@@ -586,34 +603,7 @@ class HacsBase:
async def startup_tasks(self, _=None) -> None: async def startup_tasks(self, _=None) -> None:
"""Tasks that are started after setup.""" """Tasks that are started after setup."""
self.set_stage(HacsStage.STARTUP) self.set_stage(HacsStage.STARTUP)
await self.async_load_hacs_from_github()
try:
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
if repository is None:
await self.async_register_repository(
repository_full_name=HacsGitHubRepo.INTEGRATION,
category=HacsCategory.INTEGRATION,
default=True,
)
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
if repository is None:
raise HacsException("Unknown error")
repository.data.installed = True
repository.data.installed_version = self.integration.version.string
repository.data.new = False
repository.data.releases = True
self.repository = repository.repository_object
self.repositories.mark_default(repository)
except HacsException as exception:
if "403" in str(exception):
self.log.critical(
"GitHub API is ratelimited, or the token is wrong.",
)
else:
self.log.critical("Could not load HACS! - %s", exception)
self.disable_hacs(HacsDisabledReason.LOAD_HACS)
if critical := await async_load_from_store(self.hass, "critical"): if critical := await async_load_from_store(self.hass, "critical"):
for repo in critical: for repo in critical:
@@ -624,16 +614,38 @@ class HacsBase:
) )
break break
if not self.configuration.experimental:
self.recuring_tasks.append( self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval( self.hass.helpers.event.async_track_time_interval(
self.async_get_all_category_repositories, timedelta(hours=3) self.async_update_downloaded_repositories, timedelta(hours=48)
) )
) )
self.recuring_tasks.append( self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval( self.hass.helpers.event.async_track_time_interval(
self.async_update_all_repositories, timedelta(hours=25) self.async_update_all_repositories,
timedelta(hours=96),
) )
) )
else:
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_load_hacs_from_github,
timedelta(hours=48),
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_update_downloaded_custom_repositories, timedelta(hours=48)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_get_all_category_repositories, timedelta(hours=6)
)
)
self.recuring_tasks.append( self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval( self.hass.helpers.event.async_track_time_interval(
self.async_check_rate_limit, timedelta(minutes=5) self.async_check_rate_limit, timedelta(minutes=5)
@@ -644,14 +656,10 @@ class HacsBase:
self.async_prosess_queue, timedelta(minutes=10) self.async_prosess_queue, timedelta(minutes=10)
) )
) )
self.recuring_tasks.append( self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval( self.hass.helpers.event.async_track_time_interval(
self.async_update_downloaded_repositories, timedelta(hours=2) self.async_handle_critical_repositories, timedelta(hours=6)
)
)
self.recuring_tasks.append(
self.hass.helpers.event.async_track_time_interval(
self.async_handle_critical_repositories, timedelta(hours=2)
) )
) )
@@ -659,6 +667,8 @@ class HacsBase:
EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write
) )
self.log.debug("There are %s scheduled recurring tasks", len(self.recuring_tasks))
self.status.startup = False self.status.startup = False
self.async_dispatch(HacsDispatchEvent.STATUS, {}) self.async_dispatch(HacsDispatchEvent.STATUS, {})
@@ -756,6 +766,42 @@ class HacsBase:
if self.configuration.netdaemon: if self.configuration.netdaemon:
self.enable_hacs_category(HacsCategory.NETDAEMON) self.enable_hacs_category(HacsCategory.NETDAEMON)
async def async_load_hacs_from_github(self, _=None) -> None:
"""Load HACS from GitHub."""
if self.configuration.experimental and self.status.inital_fetch_done:
return
try:
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
if repository is None:
await self.async_register_repository(
repository_full_name=HacsGitHubRepo.INTEGRATION,
category=HacsCategory.INTEGRATION,
default=True,
)
repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
elif self.configuration.experimental and not self.status.startup:
self.log.error("Scheduling update of hacs/integration")
self.queue.add(repository.common_update())
if repository is None:
raise HacsException("Unknown error")
repository.data.installed = True
repository.data.installed_version = self.integration.version.string
repository.data.new = False
repository.data.releases = True
self.repository = repository.repository_object
self.repositories.mark_default(repository)
except HacsException as exception:
if "403" in str(exception):
self.log.critical(
"GitHub API is ratelimited, or the token is wrong.",
)
else:
self.log.critical("Could not load HACS! - %s", exception)
self.disable_hacs(HacsDisabledReason.LOAD_HACS)
async def async_get_all_category_repositories(self, _=None) -> None: async def async_get_all_category_repositories(self, _=None) -> None:
"""Get all category repositories.""" """Get all category repositories."""
if self.system.disabled: if self.system.disabled:
@@ -763,11 +809,62 @@ class HacsBase:
self.log.info("Loading known repositories") self.log.info("Loading known repositories")
await asyncio.gather( await asyncio.gather(
*[ *[
self.async_get_category_repositories(HacsCategory(category)) self.async_get_category_repositories_experimental(category)
if self.configuration.experimental
else self.async_get_category_repositories(HacsCategory(category))
for category in self.common.categories or [] for category in self.common.categories or []
] ]
) )
async def async_get_category_repositories_experimental(self, category: str) -> None:
"""Update all category repositories."""
self.log.debug("Fetching updated content for %s", category)
try:
category_data = await self.data_client.get_data(category)
except HacsNotModifiedException:
self.log.debug("No updates for %s", category)
return
except HacsException as exception:
self.log.error("Could not update %s - %s", category, exception)
return
await self.data.register_unknown_repositories(category_data, category)
for repo_id, repo_data in category_data.items():
repo = repo_data["full_name"]
if self.common.renamed_repositories.get(repo):
repo = self.common.renamed_repositories[repo]
if self.repositories.is_removed(repo):
continue
if repo in self.common.archived_repositories:
continue
if repository := self.repositories.get_by_full_name(repo):
self.repositories.set_repository_id(repository, repo_id)
self.repositories.mark_default(repository)
if repository.data.last_fetched is None or (
repository.data.last_fetched.timestamp() < repo_data["last_fetched"]
):
repository.data.update_data({**dict(REPOSITORY_KEYS_TO_EXPORT), **repo_data})
if (manifest := repo_data.get("manifest")) is not None:
repository.repository_manifest.update_data(
{**dict(HACS_MANIFEST_KEYS_TO_EXPORT), **manifest}
)
if category == "integration":
self.status.inital_fetch_done = True
if self.stage == HacsStage.STARTUP:
for repository in self.repositories.list_all:
if (
repository.data.category == category
and not repository.data.installed
and not self.repositories.is_default(repository.data.id)
):
repository.logger.debug(
"%s Unregister stale custom repository", repository.string
)
self.repositories.unregister(repository)
async def async_get_category_repositories(self, category: HacsCategory) -> None: async def async_get_category_repositories(self, category: HacsCategory) -> None:
"""Get repositories from category.""" """Get repositories from category."""
if self.system.disabled: if self.system.disabled:
@@ -843,7 +940,7 @@ class HacsBase:
return return
can_update = await self.async_can_update() can_update = await self.async_can_update()
self.log.debug( self.log.debug(
"Can update %s repositories, " "items in queue %s", "Can update %s repositories, items in queue %s",
can_update, can_update,
self.queue.pending_tasks, self.queue.pending_tasks,
) )
@@ -865,6 +962,9 @@ class HacsBase:
self.log.info("Loading removed repositories") self.log.info("Loading removed repositories")
try: try:
if self.configuration.experimental:
removed_repositories = await self.data_client.get_data("removed")
else:
removed_repositories = await self.async_github_get_hacs_default_file( removed_repositories = await self.async_github_get_hacs_default_file(
HacsCategory.REMOVED HacsCategory.REMOVED
) )
@@ -913,7 +1013,7 @@ class HacsBase:
async def async_update_downloaded_repositories(self, _=None) -> None: async def async_update_downloaded_repositories(self, _=None) -> None:
"""Execute the task.""" """Execute the task."""
if self.system.disabled: if self.system.disabled or self.configuration.experimental:
return return
self.log.info("Starting recurring background task for downloaded repositories") self.log.info("Starting recurring background task for downloaded repositories")
@@ -923,6 +1023,21 @@ class HacsBase:
self.log.debug("Recurring background task for downloaded repositories done") self.log.debug("Recurring background task for downloaded repositories done")
async def async_update_downloaded_custom_repositories(self, _=None) -> None:
"""Execute the task."""
if self.system.disabled or not self.configuration.experimental:
return
self.log.info("Starting recurring background task for downloaded custom repositories")
for repository in self.repositories.list_downloaded:
if (
repository.data.category in self.common.categories
and not self.repositories.is_default(repository.data.id)
):
self.queue.add(repository.update_repository(ignore_issues=True))
self.log.debug("Recurring background task for downloaded custom repositories done")
async def async_handle_critical_repositories(self, _=None) -> None: async def async_handle_critical_repositories(self, _=None) -> None:
"""Handle critical repositories.""" """Handle critical repositories."""
critical_queue = QueueManager(hass=self.hass) critical_queue = QueueManager(hass=self.hass)
@@ -931,8 +1046,11 @@ class HacsBase:
was_installed = False was_installed = False
try: try:
if self.configuration.experimental:
critical = await self.data_client.get_data("critical")
else:
critical = await self.async_github_get_hacs_default_file("critical") critical = await self.async_github_get_hacs_default_file("critical")
except GitHubNotModifiedException: except (GitHubNotModifiedException, HacsNotModifiedException):
return return
except HacsException: except HacsException:
pass pass
@@ -984,3 +1102,43 @@ class HacsBase:
if was_installed: if was_installed:
self.log.critical("Restarting Home Assistant") self.log.critical("Restarting Home Assistant")
self.hass.async_create_task(self.hass.async_stop(100)) self.hass.async_create_task(self.hass.async_stop(100))
@callback
def async_setup_frontend_endpoint_plugin(self) -> None:
"""Setup the http endpoints for plugins if its not already handled."""
if self.status.active_frontend_endpoint_plugin or not os.path.exists(
self.hass.config.path("www/community")
):
return
self.log.info("Setting up plugin endpoint")
use_cache = self.core.lovelace_mode == "storage"
self.log.info(
"<HacsFrontend> %s mode, cache for /hacsfiles/: %s",
self.core.lovelace_mode,
use_cache,
)
self.hass.http.register_static_path(
URL_BASE,
self.hass.config.path("www/community"),
cache_headers=use_cache,
)
self.status.active_frontend_endpoint_plugin = True
@callback
def async_setup_frontend_endpoint_themes(self) -> None:
"""Setup the http endpoints for themes if its not already handled."""
if (
self.configuration.experimental
or self.status.active_frontend_endpoint_theme
or not os.path.exists(self.hass.config.path("themes"))
):
return
self.log.info("Setting up themes endpoint")
# Register themes
self.hass.http.register_static_path(f"{URL_BASE}/themes", self.hass.config.path("themes"))
self.status.active_frontend_endpoint_theme = True

View File

@@ -6,7 +6,9 @@ from aiogithubapi.common.const import ACCEPT_HEADERS
NAME_SHORT = "HACS" NAME_SHORT = "HACS"
DOMAIN = "hacs" DOMAIN = "hacs"
CLIENT_ID = "395a8e669c5de9f7c6e8" CLIENT_ID = "395a8e669c5de9f7c6e8"
MINIMUM_HA_VERSION = "2022.10.0" MINIMUM_HA_VERSION = "2022.11.0"
URL_BASE = "/hacsfiles"
TV = TypeVar("TV") TV = TypeVar("TV")
@@ -15,6 +17,8 @@ PACKAGE_NAME = "custom_components.hacs"
DEFAULT_CONCURRENT_TASKS = 15 DEFAULT_CONCURRENT_TASKS = 15
DEFAULT_CONCURRENT_BACKOFF_TIME = 1 DEFAULT_CONCURRENT_BACKOFF_TIME = 1
HACS_REPOSITORY_ID = "172733314"
HACS_ACTION_GITHUB_API_HEADERS = { HACS_ACTION_GITHUB_API_HEADERS = {
"User-Agent": "HACS/action", "User-Agent": "HACS/action",
"Accept": ACCEPT_HEADERS["preview"], "Accept": ACCEPT_HEADERS["preview"],

View File

@@ -1,16 +1,30 @@
"""Helper constants.""" """Helper constants."""
# pylint: disable=missing-class-docstring # pylint: disable=missing-class-docstring
import sys
if sys.version_info.minor >= 11:
# Needs Python 3.11
from enum import StrEnum # # pylint: disable=no-name-in-module
else:
try:
# https://github.com/home-assistant/core/blob/dev/homeassistant/backports/enum.py
# Considered internal to Home Assistant, can be removed whenever.
from homeassistant.backports.enum import StrEnum
except ImportError:
from enum import Enum from enum import Enum
class StrEnum(str, Enum):
pass
class HacsGitHubRepo(str, Enum):
class HacsGitHubRepo(StrEnum):
"""HacsGitHubRepo.""" """HacsGitHubRepo."""
DEFAULT = "hacs/default" DEFAULT = "hacs/default"
INTEGRATION = "hacs/integration" INTEGRATION = "hacs/integration"
class HacsCategory(str, Enum): class HacsCategory(StrEnum):
APPDAEMON = "appdaemon" APPDAEMON = "appdaemon"
INTEGRATION = "integration" INTEGRATION = "integration"
LOVELACE = "lovelace" LOVELACE = "lovelace"
@@ -24,7 +38,7 @@ class HacsCategory(str, Enum):
return str(self.value) return str(self.value)
class HacsDispatchEvent(str, Enum): class HacsDispatchEvent(StrEnum):
"""HacsDispatchEvent.""" """HacsDispatchEvent."""
CONFIG = "hacs_dispatch_config" CONFIG = "hacs_dispatch_config"
@@ -37,19 +51,19 @@ class HacsDispatchEvent(str, Enum):
STATUS = "hacs_dispatch_status" STATUS = "hacs_dispatch_status"
class RepositoryFile(str, Enum): class RepositoryFile(StrEnum):
"""Repository file names.""" """Repository file names."""
HACS_JSON = "hacs.json" HACS_JSON = "hacs.json"
MAINIFEST_JSON = "manifest.json" MAINIFEST_JSON = "manifest.json"
class ConfigurationType(str, Enum): class ConfigurationType(StrEnum):
YAML = "yaml" YAML = "yaml"
CONFIG_ENTRY = "config_entry" CONFIG_ENTRY = "config_entry"
class LovelaceMode(str, Enum): class LovelaceMode(StrEnum):
"""Lovelace Modes.""" """Lovelace Modes."""
STORAGE = "storage" STORAGE = "storage"
@@ -58,7 +72,7 @@ class LovelaceMode(str, Enum):
YAML = "yaml" YAML = "yaml"
class HacsStage(str, Enum): class HacsStage(StrEnum):
SETUP = "setup" SETUP = "setup"
STARTUP = "startup" STARTUP = "startup"
WAITING = "waiting" WAITING = "waiting"
@@ -66,7 +80,7 @@ class HacsStage(str, Enum):
BACKGROUND = "background" BACKGROUND = "background"
class HacsDisabledReason(str, Enum): class HacsDisabledReason(StrEnum):
RATE_LIMIT = "rate_limit" RATE_LIMIT = "rate_limit"
REMOVED = "removed" REMOVED = "removed"
INVALID_TOKEN = "invalid_token" INVALID_TOKEN = "invalid_token"

View File

@@ -7,15 +7,13 @@ from aiohttp import web
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN from .const import DOMAIN, URL_BASE
from .hacs_frontend import locate_dir, VERSION as FE_VERSION from .hacs_frontend import VERSION as FE_VERSION, locate_dir
from .hacs_frontend_experimental import ( from .hacs_frontend_experimental import (
locate_dir as experimental_locate_dir,
VERSION as EXPERIMENTAL_FE_VERSION, VERSION as EXPERIMENTAL_FE_VERSION,
locate_dir as experimental_locate_dir,
) )
URL_BASE = "/hacsfiles"
if TYPE_CHECKING: if TYPE_CHECKING:
from .base import HacsBase from .base import HacsBase
@@ -24,8 +22,8 @@ if TYPE_CHECKING:
def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None: def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
"""Register the frontend.""" """Register the frontend."""
# Register themes # Setup themes endpoint if needed
hass.http.register_static_path(f"{URL_BASE}/themes", hass.config.path("themes")) hacs.async_setup_frontend_endpoint_themes()
# Register frontend # Register frontend
if hacs.configuration.frontend_repo_url: if hacs.configuration.frontend_repo_url:
@@ -50,20 +48,6 @@ def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
hass.data["frontend_extra_module_url"] = set() hass.data["frontend_extra_module_url"] = set()
hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js") hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js")
# Register www/community for all other files
use_cache = hacs.core.lovelace_mode == "storage"
hacs.log.info(
"<HacsFrontend> %s mode, cache for /hacsfiles/: %s",
hacs.core.lovelace_mode,
use_cache,
)
hass.http.register_static_path(
URL_BASE,
hass.config.path("www/community"),
cache_headers=use_cache,
)
hacs.frontend_version = ( hacs.frontend_version = (
FE_VERSION if not hacs.configuration.experimental else EXPERIMENTAL_FE_VERSION FE_VERSION if not hacs.configuration.experimental else EXPERIMENTAL_FE_VERSION
) )
@@ -86,6 +70,9 @@ def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
require_admin=True, require_admin=True,
) )
# Setup plugin endpoint if needed
hacs.async_setup_frontend_endpoint_plugin()
class HacsFrontendDev(HomeAssistantView): class HacsFrontendDev(HomeAssistantView):
"""Dev View Class for HACS.""" """Dev View Class for HACS."""

View File

@@ -17,7 +17,7 @@
"issue_tracker": "https://github.com/hacs/integration/issues", "issue_tracker": "https://github.com/hacs/integration/issues",
"name": "HACS", "name": "HACS",
"requirements": [ "requirements": [
"aiogithubapi>=22.2.4" "aiogithubapi>=22.10.1"
], ],
"version": "1.28.3" "version": "1.30.1"
} }

View File

@@ -50,16 +50,27 @@ if TYPE_CHECKING:
TOPIC_FILTER = ( TOPIC_FILTER = (
"add-on",
"addon",
"app",
"appdaemon-apps",
"appdaemon",
"custom-card", "custom-card",
"custom-cards",
"custom-component", "custom-component",
"custom-components", "custom-components",
"customcomponents", "customcomponents",
"hacktoberfest", "hacktoberfest",
"hacs-default", "hacs-default",
"hacs-integration", "hacs-integration",
"hacs-repository",
"hacs", "hacs",
"hass", "hass",
"hassio", "hassio",
"home-assistant-custom",
"home-assistant-frontend",
"home-assistant-hacs",
"home-assistant-sensor",
"home-assistant", "home-assistant",
"home-automation", "home-automation",
"homeassistant-components", "homeassistant-components",
@@ -68,16 +79,45 @@ TOPIC_FILTER = (
"homeassistant", "homeassistant",
"homeautomation", "homeautomation",
"integration", "integration",
"lovelace-ui",
"lovelace", "lovelace",
"media-player",
"mediaplayer",
"netdaemon",
"plugin",
"python_script",
"python-script",
"python", "python",
"sensor", "sensor",
"smart-home",
"smarthome",
"theme", "theme",
"themes", "themes",
"custom-cards", )
"home-assistant-frontend",
"home-assistant-hacs",
"home-assistant-custom", REPOSITORY_KEYS_TO_EXPORT = (
"lovelace-ui", # Keys can not be removed from this list until v3
# If keys are added, the action need to be re-run with force
("description", ""),
("downloads", 0),
("domain", None),
("etag_repository", None),
("full_name", ""),
("last_commit", None),
("last_updated", 0),
("last_version", None),
("manifest_name", None),
("open_issues", 0),
("stargazers_count", 0),
("topics", []),
)
HACS_MANIFEST_KEYS_TO_EXPORT = (
# Keys can not be removed from this list until v3
# If keys are added, the action need to be re-run with force
("country", []),
("name", None),
) )
@@ -120,7 +160,6 @@ class RepositoryData:
new: bool = True new: bool = True
open_issues: int = 0 open_issues: int = 0
published_tags: list[str] = [] published_tags: list[str] = []
pushed_at: str = ""
releases: bool = False releases: bool = False
selected_tag: str = None selected_tag: str = None
show_beta: bool = False show_beta: bool = False
@@ -147,32 +186,24 @@ class RepositoryData:
def update_data(self, data: dict, action: bool = False) -> None: def update_data(self, data: dict, action: bool = False) -> None:
"""Update data of the repository.""" """Update data of the repository."""
for key in data: for key, value in data.items():
if key not in self.__dict__: if key not in self.__dict__:
continue continue
if key == "pushed_at":
if data[key] == "": if key == "last_fetched" and isinstance(value, float):
continue setattr(self, key, datetime.fromtimestamp(value))
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": elif key == "id":
setattr(self, key, str(data[key])) setattr(self, key, str(value))
elif key == "country": elif key == "country":
if isinstance(data[key], str): if isinstance(value, str):
setattr(self, key, [data[key]]) setattr(self, key, [value])
else: else:
setattr(self, key, data[key]) setattr(self, key, value)
elif key == "topics" and not action: elif key == "topics" and not action:
setattr(self, key, [topic for topic in data[key] if topic not in TOPIC_FILTER]) setattr(self, key, [topic for topic in value if topic not in TOPIC_FILTER])
else: else:
setattr(self, key, data[key]) setattr(self, key, value)
@attr.s(auto_attribs=True) @attr.s(auto_attribs=True)
@@ -215,6 +246,20 @@ class HacsManifest:
setattr(manifest_data, key, value) setattr(manifest_data, key, value)
return manifest_data return manifest_data
def update_data(self, data: dict) -> None:
"""Update the manifest data."""
for key, value in data.items():
if key not in self.__dict__:
continue
if key == "country":
if isinstance(value, str):
setattr(self, key, [value])
else:
setattr(self, key, value)
else:
setattr(self, key, value)
class RepositoryReleases: class RepositoryReleases:
"""RepositoyReleases.""" """RepositoyReleases."""
@@ -449,6 +494,10 @@ class HacsRepository:
self.logger.debug("%s Did not update, content was not modified", self.string) self.logger.debug("%s Did not update, content was not modified", self.string)
return return
if self.repository_object:
self.data.last_updated = self.repository_object.attributes.get("pushed_at", 0)
self.data.last_fetched = datetime.utcnow()
# Set topics # Set topics
self.data.topics = self.data.topics self.data.topics = self.data.topics
@@ -497,7 +546,7 @@ class HacsRepository:
self.additional_info = await self.async_get_info_file_contents() self.additional_info = await self.async_get_info_file_contents()
# Set last fetch attribute # Set last fetch attribute
self.data.last_fetched = datetime.now() self.data.last_fetched = datetime.utcnow()
return True return True
@@ -1011,7 +1060,11 @@ class HacsRepository:
self.hacs.common.renamed_repositories[ self.hacs.common.renamed_repositories[
self.data.full_name self.data.full_name
] = repository_object.full_name ] = repository_object.full_name
if not self.hacs.system.generator:
raise HacsRepositoryExistException raise HacsRepositoryExistException
self.logger.error(
"%s Repository has been renamed - %s", self.string, repository_object.full_name
)
self.data.update_data( self.data.update_data(
repository_object.attributes, repository_object.attributes,
action=self.hacs.system.action, action=self.hacs.system.action,

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from homeassistant.helpers.issue_registry import async_create_issue, IssueSeverity from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.loader import async_get_custom_components from homeassistant.loader import async_get_custom_components
from ..const import DOMAIN from ..const import DOMAIN

View File

@@ -53,6 +53,10 @@ class HacsPluginRepository(HacsRepository):
self.logger.error("%s %s", self.string, error) self.logger.error("%s %s", self.string, error)
return self.validate.success return self.validate.success
async def async_post_installation(self):
"""Run post installation steps."""
self.hacs.async_setup_frontend_endpoint_plugin()
@concurrent(concurrenttasks=10, backoff_time=5) @concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False): async def update_repository(self, ignore_issues=False, force=False):
"""Update.""" """Update."""

View File

@@ -37,6 +37,8 @@ class HacsThemeRepository(HacsRepository):
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
pass pass
self.hacs.async_setup_frontend_endpoint_themes()
async def validate_repository(self): async def validate_repository(self):
"""Validate.""" """Validate."""
# Run common validation steps. # Run common validation steps.

View File

@@ -1,5 +1,6 @@
"""Sensor platform for HACS.""" """Sensor platform for HACS."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity

View File

@@ -7,6 +7,7 @@ from .base import HacsBase
from .const import DOMAIN from .const import DOMAIN
GITHUB_STATUS = "https://www.githubstatus.com/" GITHUB_STATUS = "https://www.githubstatus.com/"
CLOUDFLARE_STATUS = "https://www.cloudflarestatus.com/"
@callback @callback
@@ -39,4 +40,9 @@ async def system_health_info(hass):
if hacs.system.disabled: if hacs.system.disabled:
data["Disabled"] = hacs.system.disabled_reason data["Disabled"] = hacs.system.disabled_reason
if hacs.configuration.experimental:
data["HACS Data"] = system_health.async_check_can_reach_url(
hass, "https://data-v2.hacs.xyz/data.json", CLOUDFLARE_STATUS
)
return data return data

View File

@@ -90,6 +90,17 @@ class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
if self.repository.pending_restart or not self.repository.can_download: if self.repository.pending_restart or not self.repository.can_download:
return None return None
if self.latest_version not in self.repository.data.published_tags:
releases = await self.repository.get_releases(
prerelease=self.repository.data.show_beta,
returnlimit=self.hacs.configuration.release_limit,
)
if releases:
self.repository.data.releases = True
self.repository.releases.objects = releases
self.repository.data.published_tags = [x.tag_name for x in releases]
self.repository.data.last_version = next(iter(self.repository.data.published_tags))
release_notes = "" release_notes = ""
if len(self.repository.releases.objects) > 0: if len(self.repository.releases.objects) > 0:
release = self.repository.releases.objects[0] release = self.repository.releases.objects[0]

View File

@@ -1,38 +1,45 @@
"""Data handler for HACS.""" """Data handler for HACS."""
from __future__ import annotations
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from typing import Any
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import json as json_util from homeassistant.util import json as json_util
from ..base import HacsBase from ..base import HacsBase
from ..enums import HacsDisabledReason, HacsDispatchEvent, HacsGitHubRepo from ..const import HACS_REPOSITORY_ID
from ..enums import HacsDisabledReason, HacsDispatchEvent
from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository
from .logger import LOGGER from .logger import LOGGER
from .path import is_safe from .path import is_safe
from .store import async_load_from_store, async_save_to_store from .store import async_load_from_store, async_save_to_store
DEFAULT_BASE_REPOSITORY_DATA = ( EXPORTED_BASE_DATA = (
("new", False),
("full_name", ""),
)
EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (
("authors", []), ("authors", []),
("category", ""), ("category", ""),
("description", ""), ("description", ""),
("domain", None), ("domain", None),
("downloads", 0), ("downloads", 0),
("etag_repository", None), ("etag_repository", None),
("full_name", ""),
("last_updated", 0),
("hide", False), ("hide", False),
("last_updated", 0),
("new", False), ("new", False),
("stargazers_count", 0), ("stargazers_count", 0),
("topics", []), ("topics", []),
) )
DEFAULT_EXTENDED_REPOSITORY_DATA = ( EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
("archived", False), ("archived", False),
("config_flow", False), ("config_flow", False),
("default_branch", None), ("default_branch", None),
("description", ""),
("first_install", False), ("first_install", False),
("installed_commit", None), ("installed_commit", None),
("installed", False), ("installed", False),
@@ -41,12 +48,9 @@ DEFAULT_EXTENDED_REPOSITORY_DATA = (
("manifest_name", None), ("manifest_name", None),
("open_issues", 0), ("open_issues", 0),
("published_tags", []), ("published_tags", []),
("pushed_at", ""),
("releases", False), ("releases", False),
("selected_tag", None), ("selected_tag", None),
("show_beta", False), ("show_beta", False),
("stargazers_count", 0),
("topics", []),
) )
@@ -80,6 +84,8 @@ class HacsData:
"ignored_repositories": self.hacs.common.ignored_repositories, "ignored_repositories": self.hacs.common.ignored_repositories,
}, },
) )
if self.hacs.configuration.experimental:
await self._async_store_experimental_content_and_repos()
await self._async_store_content_and_repos() await self._async_store_content_and_repos()
async def _async_store_content_and_repos(self, _=None): # bb: ignore async def _async_store_content_and_repos(self, _=None): # bb: ignore
@@ -94,40 +100,94 @@ class HacsData:
for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG): for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG):
self.hacs.async_dispatch(event, {}) self.hacs.async_dispatch(event, {})
async def _async_store_experimental_content_and_repos(self, _=None): # bb: ignore
"""Store the main repos file and each repo that is out of date."""
# Repositories
self.content = {}
for repository in self.hacs.repositories.list_all:
if repository.data.category in self.hacs.common.categories:
self.async_store_experimental_repository_data(repository)
await async_save_to_store(self.hacs.hass, "data", {"repositories": self.content})
@callback @callback
def async_store_repository_data(self, repository: HacsRepository) -> dict: def async_store_repository_data(self, repository: HacsRepository) -> dict:
"""Store the repository data.""" """Store the repository data."""
data = {"repository_manifest": repository.repository_manifest.manifest} data = {"repository_manifest": repository.repository_manifest.manifest}
for key, default_value in DEFAULT_BASE_REPOSITORY_DATA: for key, default in (
if (value := repository.data.__getattribute__(key)) != default_value: EXPORTED_DOWNLOADED_REPOSITORY_DATA
if repository.data.installed
else EXPORTED_REPOSITORY_DATA
):
if (value := getattr(repository.data, key, default)) != default:
data[key] = value data[key] = value
if repository.data.installed: if repository.data.installed_version:
for key, default_value in DEFAULT_EXTENDED_REPOSITORY_DATA:
if (value := repository.data.__getattribute__(key)) != default_value:
data[key] = value
data["version_installed"] = repository.data.installed_version data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched: if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp() data["last_fetched"] = repository.data.last_fetched.timestamp()
self.content[str(repository.data.id)] = data self.content[str(repository.data.id)] = data
@callback
def async_store_experimental_repository_data(self, repository: HacsRepository) -> None:
"""Store the experimental repository data for non downloaded repositories."""
data = {}
self.content.setdefault(repository.data.category, [])
if repository.data.installed:
data["repository_manifest"] = repository.repository_manifest.manifest
for key, default in EXPORTED_DOWNLOADED_REPOSITORY_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
if repository.data.installed_version:
data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp()
else:
for key, default in EXPORTED_BASE_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
self.content[repository.data.category].append({"id": str(repository.data.id), **data})
async def restore(self): async def restore(self):
"""Restore saved data.""" """Restore saved data."""
self.hacs.status.new = False self.hacs.status.new = False
try: repositories = {}
hacs = await async_load_from_store(self.hacs.hass, "hacs") or {}
except HomeAssistantError:
hacs = {} hacs = {}
try: try:
repositories = await async_load_from_store(self.hacs.hass, "repositories") or {} hacs = await async_load_from_store(self.hacs.hass, "hacs") or {}
except HomeAssistantError:
pass
try:
data = (
await async_load_from_store(
self.hacs.hass,
"data" if self.hacs.configuration.experimental else "repositories",
)
or {}
)
if data and self.hacs.configuration.experimental:
for category, entries in data.get("repositories", {}).items():
for repository in entries:
repositories[repository["id"]] = {"category": category, **repository}
else:
repositories = (
data or await async_load_from_store(self.hacs.hass, "repositories") or {}
)
except HomeAssistantError as exception: except HomeAssistantError as exception:
self.hacs.log.error( self.hacs.log.error(
"Could not read %s, restore the file from a backup - %s", "Could not read %s, restore the file from a backup - %s",
self.hacs.hass.config.path(".storage/hacs.repositories"), self.hacs.hass.config.path(
".storage/hacs.data"
if self.hacs.configuration.experimental
else ".storage/hacs.repositories"
),
exception, exception,
) )
self.hacs.disable_hacs(HacsDisabledReason.RESTORE) self.hacs.disable_hacs(HacsDisabledReason.RESTORE)
@@ -136,6 +196,8 @@ class HacsData:
if not hacs and not repositories: if not hacs and not repositories:
# Assume new install # Assume new install
self.hacs.status.new = True self.hacs.status.new = True
if self.hacs.configuration.experimental:
return True
self.logger.info("<HacsData restore> Loading base repository information") self.logger.info("<HacsData restore> Loading base repository information")
repositories = await self.hacs.hass.async_add_executor_job( repositories = await self.hacs.hass.async_add_executor_job(
json_util.load_json, json_util.load_json,
@@ -186,28 +248,34 @@ class HacsData:
return False return False
return True return True
async def register_unknown_repositories(self, repositories): async def register_unknown_repositories(self, repositories, category: str | None = None):
"""Registry any unknown repositories.""" """Registry any unknown repositories."""
register_tasks = [ register_tasks = [
self.hacs.async_register_repository( self.hacs.async_register_repository(
repository_full_name=repo_data["full_name"], repository_full_name=repo_data["full_name"],
category=repo_data["category"], category=repo_data.get("category", category),
check=False, check=False,
repository_id=entry, repository_id=entry,
) )
for entry, repo_data in repositories.items() for entry, repo_data in repositories.items()
if entry != "0" and not self.hacs.repositories.is_registered(repository_id=entry) if entry != "0"
and not self.hacs.repositories.is_registered(repository_id=entry)
and repo_data.get("category", category) is not None
] ]
if register_tasks: if register_tasks:
await asyncio.gather(*register_tasks) await asyncio.gather(*register_tasks)
@callback @callback
def async_restore_repository(self, entry, repository_data): def async_restore_repository(self, entry: str, repository_data: dict[str, Any]):
"""Restore repository.""" """Restore repository."""
full_name = repository_data["full_name"] repository: HacsRepository | None = None
if not (repository := self.hacs.repositories.get_by_full_name(full_name)): if full_name := repository_data.get("full_name"):
self.logger.error("<HacsData restore> Did not find %s (%s)", full_name, entry) repository = self.hacs.repositories.get_by_full_name(full_name)
if not repository:
repository = self.hacs.repositories.get_by_id(entry)
if not repository:
return return
# Restore repository attributes # Restore repository attributes
self.hacs.repositories.set_repository_id(repository, entry) self.hacs.repositories.set_repository_id(repository, entry)
repository.data.authors = repository_data.get("authors", []) repository.data.authors = repository_data.get("authors", [])
@@ -238,7 +306,7 @@ class HacsData:
repository.data.last_fetched = datetime.fromtimestamp(last_fetched) repository.data.last_fetched = datetime.fromtimestamp(last_fetched)
repository.repository_manifest = HacsManifest.from_dict( repository.repository_manifest = HacsManifest.from_dict(
repository_data.get("repository_manifest", {}) repository_data.get("manifest") or repository_data.get("repository_manifest") or {}
) )
if repository.localpath is not None and is_safe(self.hacs, repository.localpath): if repository.localpath is not None and is_safe(self.hacs, repository.localpath):
@@ -248,6 +316,6 @@ class HacsData:
if repository.data.installed: if repository.data.installed:
repository.data.first_install = False repository.data.first_install = False
if full_name == HacsGitHubRepo.INTEGRATION: if entry == HACS_REPOSITORY_ID:
repository.data.installed_version = self.hacs.version repository.data.installed_version = self.hacs.version
repository.data.installed = True repository.data.installed = True

File diff suppressed because one or more lines are too long