diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index 5b5c4de65a9..143696ecbaa 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -20,6 +20,8 @@ from homeassistant.loader import async_get_integration import voluptuous as vol +from custom_components.hacs.validate.manager import ValidationManager + from .base import HacsBase from .const import DOMAIN, PLATFORMS, STARTUP from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode @@ -82,6 +84,7 @@ async def async_initialize_integration( hacs.system.running = True hacs.session = async_create_clientsession(hass) hacs.tasks = HacsTaskManager(hacs=hacs, hass=hass) + hacs.validation = ValidationManager(hacs=hacs, hass=hass) hacs.core.lovelace_mode = LovelaceMode.YAML try: diff --git a/custom_components/hacs/base.py b/custom_components/hacs/base.py index 0c77f8ba884..60ce15b850a 100644 --- a/custom_components/hacs/base.py +++ b/custom_components/hacs/base.py @@ -50,6 +50,7 @@ from .repositories.base import HacsRepository from .tasks.manager import HacsTaskManager from .utils.data import HacsData + from .validate.manager import ValidationManager @dataclass @@ -332,6 +333,7 @@ class HacsBase: status = HacsStatus() system = HacsSystem() tasks: HacsTaskManager | None = None + validation: ValidationManager | None = None version: str | None = None @property diff --git a/custom_components/hacs/repositories/base.py b/custom_components/hacs/repositories/base.py index cf0bf54cb3a..998a6f52f0f 100644 --- a/custom_components/hacs/repositories/base.py +++ b/custom_components/hacs/repositories/base.py @@ -33,7 +33,6 @@ version_left_higher_then_right, version_to_download, ) -from ..validate import async_run_repository_checks if TYPE_CHECKING: from ..base import HacsBase @@ -706,7 +705,7 @@ async def async_registration(self, ref=None) -> None: async def async_post_registration(self): """Run post registration steps.""" - await async_run_repository_checks(self.hacs, self) + await self.hacs.validation.async_run_repository_checks(self) async def async_pre_install(self) -> None: """Run pre install steps.""" diff --git a/custom_components/hacs/share.py b/custom_components/hacs/share.py deleted file mode 100644 index c45b2a2ebd3..00000000000 --- a/custom_components/hacs/share.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Shared HACS elements.""" - - -SHARE = { - "rules": {}, -} diff --git a/custom_components/hacs/tasks/base.py b/custom_components/hacs/tasks/base.py index d5bfc273974..7ce5eededbf 100644 --- a/custom_components/hacs/tasks/base.py +++ b/custom_components/hacs/tasks/base.py @@ -16,8 +16,6 @@ class HacsTask(LogMixin): """Hacs task base.""" - hass: HomeAssistant - events: list[str] | None = None schedule: timedelta | None = None stages: list[HacsStage] | None = None diff --git a/custom_components/hacs/validate/__init__.py b/custom_components/hacs/validate/__init__.py index 0350e3d9614..43eaa43dfd4 100644 --- a/custom_components/hacs/validate/__init__.py +++ b/custom_components/hacs/validate/__init__.py @@ -1,58 +1 @@ -from __future__ import annotations - -import asyncio -import glob -import importlib -from os.path import dirname, join, sep -from typing import TYPE_CHECKING - -from homeassistant.core import HomeAssistant - -from ..share import SHARE - -if TYPE_CHECKING: - from ..base import HacsBase - from ..repositories.base import HacsRepository - - -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: HomeAssistant) -> None: - await hass.async_add_executor_job(_initialize_rules) - - -async def async_run_repository_checks(hacs: HacsBase, repository: HacsRepository): - if not SHARE["rules"]: - await async_initialize_rules(hacs.hass) - 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) +"""Initialize validation.""" diff --git a/custom_components/hacs/validate/base.py b/custom_components/hacs/validate/base.py index 90b31e271d5..6fff7120269 100644 --- a/custom_components/hacs/validate/base.py +++ b/custom_components/hacs/validate/base.py @@ -1,51 +1,58 @@ +"""Base class for validation.""" from __future__ import annotations +from time import monotonic from typing import TYPE_CHECKING -from ..share import SHARE +from ..exceptions import HacsException if TYPE_CHECKING: from ..repositories.base import HacsRepository -class ValidationException(Exception): - pass +class ValidationException(HacsException): + """Raise when there is a validation issue.""" class ValidationBase: - action_only = False + """Base class for validation.""" + + action_only: bool = False + category: str = "common" def __init__(self, repository: HacsRepository) -> None: self.hacs = repository.hacs self.repository = repository 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) - - 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__}'") + + @property + def slug(self) -> str: + """Return the check slug.""" + return self.__class__.__module__.rsplit(".", maxsplit=1)[-1] + + async def execute_validation(self, *_, **__) -> None: + """Execute the task defined in subclass.""" + self.hacs.log.debug("Validation<%s> Starting validation", self.slug) + + start_time = monotonic() + self.failed = False + try: - await self.hacs.hass.async_add_executor_job(self.check) - await self.async_check() + if task := getattr(self, "validate", None): + await self.hacs.hass.async_add_executor_job(task) + elif task := getattr(self, "async_validate", None): + await task() # pylint: disable=not-callable except ValidationException as exception: self.failed = True - self.logger.error(exception) + self.hacs.log.error("Validation<%s> failed: %s", self.slug, exception) - def check(self): - pass - - async def async_check(self): - pass + else: + self.hacs.log.debug( + "Validation<%s> took %.3f seconds to complete", self.slug, monotonic() - start_time + ) class ActionValidationBase(ValidationBase): + """Base class for action validation.""" + action_only = True diff --git a/custom_components/hacs/validate/common/__init__.py b/custom_components/hacs/validate/common/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/custom_components/hacs/validate/common/hacs_manifest.py b/custom_components/hacs/validate/common/hacs_manifest.py deleted file mode 100644 index 3b8f8522177..00000000000 --- a/custom_components/hacs/validate/common/hacs_manifest.py +++ /dev/null @@ -1,8 +0,0 @@ -from ...enums import RepositoryFile -from ..base import ActionValidationBase, ValidationException - - -class HacsManifest(ActionValidationBase): - def check(self): - if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]: - raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file") diff --git a/custom_components/hacs/validate/common/repository_description.py b/custom_components/hacs/validate/common/repository_description.py deleted file mode 100644 index 94e4d6340fe..00000000000 --- a/custom_components/hacs/validate/common/repository_description.py +++ /dev/null @@ -1,7 +0,0 @@ -from ..base import ActionValidationBase, ValidationException - - -class RepositoryDescription(ActionValidationBase): - def check(self): - if not self.repository.data.description: - raise ValidationException("The repository has no description") diff --git a/custom_components/hacs/validate/common/repository_topics.py b/custom_components/hacs/validate/common/repository_topics.py deleted file mode 100644 index 46744a456d3..00000000000 --- a/custom_components/hacs/validate/common/repository_topics.py +++ /dev/null @@ -1,7 +0,0 @@ -from ..base import ActionValidationBase, ValidationException - - -class RepositoryTopics(ActionValidationBase): - def check(self): - if not self.repository.data.topics: - raise ValidationException("The repository has no topics") diff --git a/custom_components/hacs/validate/hacs_manifest.py b/custom_components/hacs/validate/hacs_manifest.py new file mode 100644 index 00000000000..46665e9c67a --- /dev/null +++ b/custom_components/hacs/validate/hacs_manifest.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from ..enums import RepositoryFile +from ..repositories.base import HacsRepository +from .base import ActionValidationBase, ValidationException + + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + def validate(self): + if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]: + raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file") diff --git a/custom_components/hacs/validate/integration/__init__.py b/custom_components/hacs/validate/integration/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/custom_components/hacs/validate/integration/integration_manifest.py b/custom_components/hacs/validate/integration/integration_manifest.py deleted file mode 100644 index 7ecdbef58bc..00000000000 --- a/custom_components/hacs/validate/integration/integration_manifest.py +++ /dev/null @@ -1,10 +0,0 @@ -from ...enums import RepositoryFile -from ..base import ActionValidationBase, ValidationException - - -class IntegrationManifest(ActionValidationBase, category="integration"): - def check(self): - if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]: - raise ValidationException( - f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file" - ) diff --git a/custom_components/hacs/validate/integration_manifest.py b/custom_components/hacs/validate/integration_manifest.py new file mode 100644 index 00000000000..c5fedf53eeb --- /dev/null +++ b/custom_components/hacs/validate/integration_manifest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from ..enums import RepositoryFile +from ..repositories.base import HacsRepository +from .base import ActionValidationBase, ValidationException + + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + category = "integration" + + def validate(self): + if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]: + raise ValidationException( + f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file" + ) diff --git a/custom_components/hacs/validate/manager.py b/custom_components/hacs/validate/manager.py new file mode 100644 index 00000000000..6222765567f --- /dev/null +++ b/custom_components/hacs/validate/manager.py @@ -0,0 +1,77 @@ +"""Hacs validation manager.""" +from __future__ import annotations + +import asyncio +from importlib import import_module +from pathlib import Path +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant + +from custom_components.hacs.repositories.base import HacsRepository + +from .base import ValidationBase + +if TYPE_CHECKING: + from ..base import HacsBase + + +class ValidationManager: + """Hacs validation manager.""" + + def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None: + """Initialize the setup manager class.""" + self.hacs = hacs + self.hass = hass + self._validatiors: dict[str, ValidationBase] = {} + + @property + def validatiors(self) -> dict[str, ValidationBase]: + """Return all list of all tasks.""" + return list(self._validatiors.values()) + + async def async_load(self, repository: HacsRepository) -> None: + """Load all tasks.""" + self._validatiors = {} + validator_files = Path(__file__).parent + validator_modules = ( + module.stem + for module in validator_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_validator(repository=repository): + self._validatiors[task.slug] = task + + await asyncio.gather(*[_load_module(task) for task in validator_modules]) + self.hacs.log.info("Loaded %s validators", len(self.validatiors)) + + async def async_run_repository_checks(self, repository: HacsRepository) -> None: + """Run all validators for a repository.""" + if not self.hacs.system.running: + return + + await self.async_load(repository) + + await asyncio.gather( + *[ + validator.execute_validation() + for validator in self.validatiors or [] + if (self.hacs.system.action or not validator.action_only) + and ( + validator.category == "common" or validator.category == repository.data.category + ) + ] + ) + + total = len([x for x in self.validatiors if self.hacs.system.action or not x.action_only]) + failed = len([x for x in self.validatiors if x.failed]) + + if failed != 0: + repository.logger.error("%s %s/%s checks failed", repository, failed, total) + if self.hacs.system.action: + exit(1) + else: + repository.logger.debug("%s All (%s) checks passed", repository, total) diff --git a/custom_components/hacs/validate/repository_description.py b/custom_components/hacs/validate/repository_description.py new file mode 100644 index 00000000000..f677da1a724 --- /dev/null +++ b/custom_components/hacs/validate/repository_description.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from ..repositories.base import HacsRepository +from .base import ActionValidationBase, ValidationException + + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + def validate(self): + if not self.repository.data.description: + raise ValidationException("The repository has no description") diff --git a/custom_components/hacs/validate/common/repository_information_file.py b/custom_components/hacs/validate/repository_information_file.py similarity index 55% rename from custom_components/hacs/validate/common/repository_information_file.py rename to custom_components/hacs/validate/repository_information_file.py index 86a0e9e0e63..80174481f2b 100644 --- a/custom_components/hacs/validate/common/repository_information_file.py +++ b/custom_components/hacs/validate/repository_information_file.py @@ -1,8 +1,16 @@ -from ..base import ActionValidationBase, ValidationException +from __future__ import annotations +from ..repositories.base import HacsRepository +from .base import ActionValidationBase, ValidationException -class RepositoryInformationFile(ActionValidationBase): - async def async_check(self): + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + async def async_validate(self): filenames = [x.filename.lower() for x in self.repository.tree] if self.repository.data.render_readme and "readme" in filenames: pass diff --git a/custom_components/hacs/validate/repository_topics.py b/custom_components/hacs/validate/repository_topics.py new file mode 100644 index 00000000000..b4921fd8bfe --- /dev/null +++ b/custom_components/hacs/validate/repository_topics.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from ..repositories.base import HacsRepository +from .base import ActionValidationBase, ValidationException + + +async def async_setup_validator(repository: HacsRepository) -> Validator: + """Set up this validator.""" + return Validator(repository=repository) + + +class Validator(ActionValidationBase): + def validate(self): + if not self.repository.data.topics: + raise ValidationException("The repository has no topics") diff --git a/tests/conftest.py b/tests/conftest.py index d6121f5688c..4385bd4530b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,10 +32,10 @@ HacsThemeRepository, ) from custom_components.hacs.repositories.base import HacsRepository -from custom_components.hacs.share import SHARE from custom_components.hacs.tasks.manager import HacsTaskManager from custom_components.hacs.utils.queue_manager import QueueManager from custom_components.hacs.utils.version import version_to_download +from custom_components.hacs.validate.manager import ValidationManager from tests.async_mock import MagicMock from tests.common import ( @@ -101,6 +101,7 @@ def hacs(hass: HomeAssistant): hacs_obj = HacsBase() hacs_obj.hass = hass hacs_obj.tasks = HacsTaskManager(hacs=hacs_obj, hass=hass) + hacs_obj.validation = ValidationManager(hacs=hacs_obj, hass=hass) hacs_obj.session = async_create_clientsession(hass) hacs_obj.repositories = HacsRepositories() diff --git a/tests/validate/common/test_hacs_manifest_check.py b/tests/validate/common/test_hacs_manifest_check.py index 711af5ce4e9..4e175c6d182 100644 --- a/tests/validate/common/test_hacs_manifest_check.py +++ b/tests/validate/common/test_hacs_manifest_check.py @@ -1,13 +1,13 @@ from aiogithubapi.objects.repository.content import AIOGitHubAPIRepositoryTreeContent import pytest -from custom_components.hacs.validate.common.hacs_manifest import HacsManifest +from custom_components.hacs.validate.hacs_manifest import Validator @pytest.mark.asyncio async def test_hacs_manifest_no_manifest(repository): - check = HacsManifest(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert check.failed @@ -18,6 +18,6 @@ async def test_hacs_manifest_with_manifest(repository): {"path": "hacs.json", "type": "file"}, "test/test", "main" ) ] - check = HacsManifest(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert not check.failed diff --git a/tests/validate/common/test_repository_description_check.py b/tests/validate/common/test_repository_description_check.py index e074775ac42..10eb5f66655 100644 --- a/tests/validate/common/test_repository_description_check.py +++ b/tests/validate/common/test_repository_description_check.py @@ -1,20 +1,18 @@ import pytest -from custom_components.hacs.validate.common.repository_description import ( - RepositoryDescription, -) +from custom_components.hacs.validate.repository_description import Validator @pytest.mark.asyncio async def test_repository_no_description(repository): repository.data.description = "" - check = RepositoryDescription(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert check.failed @pytest.mark.asyncio async def test_repository_hacs_description(repository): - check = RepositoryDescription(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert not check.failed diff --git a/tests/validate/common/test_repository_information_file_check.py b/tests/validate/common/test_repository_information_file_check.py index 40346990b14..52a95acabf9 100644 --- a/tests/validate/common/test_repository_information_file_check.py +++ b/tests/validate/common/test_repository_information_file_check.py @@ -1,22 +1,20 @@ from aiogithubapi.objects.repository.content import AIOGitHubAPIRepositoryTreeContent import pytest -from custom_components.hacs.validate.common.repository_information_file import ( - RepositoryInformationFile, -) +from custom_components.hacs.validate.repository_information_file import Validator @pytest.mark.asyncio async def test_no_info_file(repository): - check = RepositoryInformationFile(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert check.failed @pytest.mark.asyncio async def test_no_readme_file(repository): - check = RepositoryInformationFile(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert check.failed @@ -25,8 +23,8 @@ async def test_has_info_file(repository): repository.tree = [ AIOGitHubAPIRepositoryTreeContent({"path": "info", "type": "file"}, "test/test", "main") ] - check = RepositoryInformationFile(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert not check.failed @@ -35,8 +33,8 @@ async def test_has_info_md_file(repository): repository.tree = [ AIOGitHubAPIRepositoryTreeContent({"path": "info.md", "type": "file"}, "test/test", "main") ] - check = RepositoryInformationFile(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert not check.failed @@ -46,8 +44,8 @@ async def test_has_readme_file(repository): repository.tree = [ AIOGitHubAPIRepositoryTreeContent({"path": "readme", "type": "file"}, "test/test", "main") ] - check = RepositoryInformationFile(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert not check.failed @@ -59,6 +57,6 @@ async def test_has_readme_md_file(repository): {"path": "readme.md", "type": "file"}, "test/test", "main" ) ] - check = RepositoryInformationFile(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert not check.failed diff --git a/tests/validate/common/test_repository_topics_check.py b/tests/validate/common/test_repository_topics_check.py index 5cc70aeb12d..ec7d6ec47b3 100644 --- a/tests/validate/common/test_repository_topics_check.py +++ b/tests/validate/common/test_repository_topics_check.py @@ -1,19 +1,19 @@ import pytest -from custom_components.hacs.validate.common.repository_topics import RepositoryTopics +from custom_components.hacs.validate.repository_topics import Validator @pytest.mark.asyncio async def test_repository_no_topics(repository): repository.data.topics = [] - check = RepositoryTopics(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert check.failed @pytest.mark.asyncio async def test_repository_hacs_topics(repository): repository.data.topics = ["test"] - check = RepositoryTopics(repository) - await check._async_run_check() + check = Validator(repository) + await check.execute_validation() assert not check.failed diff --git a/tests/validate/integration/test_integration_manifest_check.py b/tests/validate/integration/test_integration_manifest_check.py index a1c28575180..0ace69bf044 100644 --- a/tests/validate/integration/test_integration_manifest_check.py +++ b/tests/validate/integration/test_integration_manifest_check.py @@ -1,15 +1,13 @@ from aiogithubapi.objects.repository.content import AIOGitHubAPIRepositoryTreeContent import pytest -from custom_components.hacs.validate.integration.integration_manifest import ( - IntegrationManifest, -) +from custom_components.hacs.validate.integration_manifest import Validator @pytest.mark.asyncio async def test_hacs_manifest_no_manifest(repository_integration): - check = IntegrationManifest(repository_integration) - await check._async_run_check() + check = Validator(repository_integration) + await check.execute_validation() assert check.failed @@ -20,6 +18,6 @@ async def test_hacs_manifest_with_manifest(repository_integration): {"path": "manifest.json", "type": "file"}, "test/test", "main" ) ] - check = IntegrationManifest(repository_integration) - await check._async_run_check() + check = Validator(repository_integration) + await check.execute_validation() assert not check.failed diff --git a/tests/validate/test_async_run_repository_checks.py b/tests/validate/test_async_run_repository_checks.py index de6a7158fa5..4748f4e819f 100644 --- a/tests/validate/test_async_run_repository_checks.py +++ b/tests/validate/test_async_run_repository_checks.py @@ -1,30 +1,20 @@ import pytest from custom_components.hacs.base import HacsBase -from custom_components.hacs.share import SHARE -from custom_components.hacs.validate import ( - async_initialize_rules, - async_run_repository_checks, -) - - -@pytest.mark.asyncio -async def test_async_initialize_rules(hacs: HacsBase): - - await async_initialize_rules(hacs.hass) @pytest.mark.asyncio async def test_async_run_repository_checks(hacs: HacsBase, repository_integration): - await async_run_repository_checks(hacs, repository_integration) + + await hacs.validation.async_run_repository_checks(repository_integration) + await hacs.hass.async_block_till_done() hacs.system.action = True hacs.system.running = True repository_integration.tree = [] with pytest.raises(SystemExit): - await async_run_repository_checks(hacs, repository_integration) + await hacs.validation.async_run_repository_checks(repository_integration) hacs.system.action = False - SHARE["rules"] = {} - await async_run_repository_checks(hacs, repository_integration) + await hacs.validation.async_run_repository_checks(repository_integration) hacs.system.running = False