core/script/hassfest/model.py
Steven Hartland 1a4738b1d4
Fix scaffolding integration generation (#138247)
* fix(scaffold): integration generation

Fix script.scaffold integration generation which was failing due to
hassfest quality check.

Add the required `quality_scale` to the generated integration
manifest.json.

Use the new `--skip-plugins` flag to skip the hassfest quality check
when generating integrations, as the quality scale rules are marked as
todo, and only run against the generated integration.

Correct typo in help for hassfest command `--plugins` flag.

Update Integration.core method to use absolute path to ensure it returns
the true if the integration is a core integration, which was causing
other checks to fail, as the integration was not being marked as core.

Always output subprocess output as it contains the error message when a
command fails, without this the user would not know why the command
failed.

Fixes: #128639

* Adjust comment language

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-02-11 16:24:04 +01:00

249 lines
7.2 KiB
Python

"""Models for manifest validator."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
import json
import pathlib
from typing import Any, Literal
@dataclass
class Error:
"""Error validating an integration."""
plugin: str
error: str
fixable: bool = False
def __str__(self) -> str:
"""Represent error as string."""
return f"[{self.plugin.upper()}] {self.error}"
@dataclass
class Config:
"""Config for the run."""
specific_integrations: list[pathlib.Path] | None
root: pathlib.Path
action: Literal["validate", "generate"]
requirements: bool
core_integrations_path: pathlib.Path = field(init=False)
errors: list[Error] = field(default_factory=list)
cache: dict[str, Any] = field(default_factory=dict)
plugins: set[str] = field(default_factory=set)
def __post_init__(self) -> None:
"""Post init."""
self.core_integrations_path = self.root / "homeassistant/components"
def add_error(self, *args: Any, **kwargs: Any) -> None:
"""Add an error."""
self.errors.append(Error(*args, **kwargs))
@dataclass
class Brand:
"""Represent a brand in our validator."""
@classmethod
def load_dir(cls, path: pathlib.Path, config: Config) -> dict[str, Brand]:
"""Load all brands in a directory."""
assert path.is_dir()
brands: dict[str, Brand] = {}
for fil in path.iterdir():
brand = cls(fil)
brand.load_brand(config)
brands[brand.domain] = brand
return brands
path: pathlib.Path
_brand: dict[str, Any] | None = None
@property
def brand(self) -> dict[str, Any]:
"""Guarded access to brand."""
assert self._brand is not None, "brand has not been loaded"
return self._brand
@property
def domain(self) -> str:
"""Integration domain."""
return self.path.stem
@property
def name(self) -> str | None:
"""Return name of the integration."""
return self.brand.get("name")
@property
def integrations(self) -> list[str]:
"""Return the sub integrations of this brand."""
return self.brand.get("integrations", [])
@property
def iot_standards(self) -> list[str]:
"""Return list of supported IoT standards."""
return self.brand.get("iot_standards", [])
def load_brand(self, config: Config) -> None:
"""Load brand file."""
if not self.path.is_file():
config.add_error("model", f"Brand file {self.path} not found")
return
try:
brand: dict[str, Any] = json.loads(self.path.read_text())
except ValueError as err:
config.add_error(
"model", f"Brand file {self.path.name} contains invalid JSON: {err}"
)
return
self._brand = brand
@dataclass
class Integration:
"""Represent an integration in our validator."""
@classmethod
def load_dir(cls, path: pathlib.Path, config: Config) -> dict[str, Integration]:
"""Load all integrations in a directory."""
assert path.is_dir()
integrations: dict[str, Integration] = {}
for fil in path.iterdir():
if fil.is_file() or fil.name == "__pycache__":
continue
init = fil / "__init__.py"
manifest = fil / "manifest.json"
if not init.exists() and not manifest.exists():
print(
f"Warning: {init} and manifest.json missing, "
"skipping directory. If this is your development "
"environment, you can safely delete this folder."
)
continue
integration = cls(fil, config)
integration.load_manifest()
integrations[integration.domain] = integration
return integrations
path: pathlib.Path
_config: Config
_manifest: dict[str, Any] | None = None
manifest_path: pathlib.Path | None = None
errors: list[Error] = field(default_factory=list)
warnings: list[Error] = field(default_factory=list)
translated_name: bool = False
@property
def manifest(self) -> dict[str, Any]:
"""Guarded access to manifest."""
assert self._manifest is not None, "manifest has not been loaded"
return self._manifest
@property
def domain(self) -> str:
"""Integration domain."""
return self.path.name
@property
def core(self) -> bool:
"""Core integration."""
return (
self.path.absolute()
.as_posix()
.startswith(self._config.core_integrations_path.as_posix())
)
@property
def disabled(self) -> str | None:
"""Return if integration is disabled."""
return self.manifest.get("disabled")
@property
def name(self) -> str:
"""Return name of the integration."""
name: str = self.manifest["name"]
return name
@property
def quality_scale(self) -> str | None:
"""Return quality scale of the integration."""
return self.manifest.get("quality_scale")
@property
def config_flow(self) -> bool:
"""Return if the integration has a config flow."""
return self.manifest.get("config_flow", False)
@property
def requirements(self) -> list[str]:
"""List of requirements."""
return self.manifest.get("requirements", [])
@property
def dependencies(self) -> list[str]:
"""List of dependencies."""
return self.manifest.get("dependencies", [])
@property
def supported_by(self) -> str:
"""Return the integration supported by this virtual integration."""
return self.manifest.get("supported_by", {})
@property
def integration_type(self) -> str:
"""Get integration_type."""
return self.manifest.get("integration_type", "hub")
@property
def iot_class(self) -> str | None:
"""Return the integration IoT Class."""
return self.manifest.get("iot_class")
@property
def iot_standards(self) -> list[str]:
"""Return the IoT standard supported by this virtual integration."""
return self.manifest.get("iot_standards", [])
def add_error(self, *args: Any, **kwargs: Any) -> None:
"""Add an error."""
self.errors.append(Error(*args, **kwargs))
def add_warning(self, *args: Any, **kwargs: Any) -> None:
"""Add a warning."""
self.warnings.append(Error(*args, **kwargs))
def load_manifest(self) -> None:
"""Load manifest."""
manifest_path = self.path / "manifest.json"
if not manifest_path.is_file():
self.add_error("model", f"Manifest file {manifest_path} not found")
return
try:
manifest: dict[str, Any] = json.loads(manifest_path.read_text())
except ValueError as err:
self.add_error("model", f"Manifest contains invalid JSON: {err}")
return
self._manifest = manifest
self.manifest_path = manifest_path
class ScaledQualityScaleTiers(IntEnum):
"""Supported manifest quality scales."""
BRONZE = 1
SILVER = 2
GOLD = 3
PLATINUM = 4