core/script/hassfest/__main__.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

266 lines
7.1 KiB
Python

"""Validate manifests."""
from __future__ import annotations
import argparse
from operator import attrgetter
from pathlib import Path
import sys
from time import monotonic
from . import (
application_credentials,
bluetooth,
codeowners,
config_flow,
config_schema,
dependencies,
dhcp,
docker,
icons,
json,
manifest,
metadata,
mqtt,
mypy_config,
quality_scale,
requirements,
services,
ssdp,
translations,
usb,
zeroconf,
)
from .model import Config, Integration
INTEGRATION_PLUGINS = [
application_credentials,
bluetooth,
codeowners,
config_schema,
dependencies,
dhcp,
icons,
json,
manifest,
mqtt,
quality_scale,
requirements,
services,
ssdp,
translations,
usb,
zeroconf,
config_flow, # This needs to run last, after translations are processed
]
HASS_PLUGINS = [
docker,
mypy_config,
metadata,
]
ALL_PLUGIN_NAMES = [
plugin.__name__.rsplit(".", maxsplit=1)[-1]
for plugin in (*INTEGRATION_PLUGINS, *HASS_PLUGINS)
]
def valid_integration_path(integration_path: Path | str) -> Path:
"""Test if it's a valid integration."""
path = Path(integration_path)
if not path.is_dir():
raise argparse.ArgumentTypeError(f"{integration_path} is not a directory.")
return path
def validate_plugins(plugin_names: str) -> list[str]:
"""Split and validate plugin names."""
all_plugin_names = set(ALL_PLUGIN_NAMES)
plugins = plugin_names.split(",")
for plugin in plugins:
if plugin not in all_plugin_names:
raise argparse.ArgumentTypeError(f"{plugin} is not a valid plugin name")
return plugins
def get_config() -> Config:
"""Return config."""
parser = argparse.ArgumentParser(description="Hassfest")
parser.add_argument(
"--action", type=str, choices=["validate", "generate"], default=None
)
parser.add_argument(
"--integration-path",
action="append",
type=valid_integration_path,
help="Validate a single integration",
)
parser.add_argument(
"--requirements",
action="store_true",
help="Validate requirements",
)
parser.add_argument(
"-p",
"--plugins",
type=validate_plugins,
default=ALL_PLUGIN_NAMES,
help="Comma-separated list of plugins to run. Valid plugin names: %(default)s",
)
parser.add_argument(
"--skip-plugins",
type=validate_plugins,
default=[],
help=f"Comma-separated list of plugins to skip. Valid plugin names: {ALL_PLUGIN_NAMES}",
)
parser.add_argument(
"--core-path",
type=Path,
default=Path(),
help="Path to core",
)
parsed = parser.parse_args()
if parsed.action is None:
parsed.action = "validate" if parsed.integration_path else "generate"
if parsed.action == "generate" and parsed.integration_path:
raise RuntimeError(
"Generate is not allowed when limiting to specific integrations"
)
if (
not parsed.integration_path
and not (parsed.core_path / "requirements_all.txt").is_file()
):
raise RuntimeError("Run from Home Assistant root")
if parsed.skip_plugins:
parsed.plugins = set(parsed.plugins) - set(parsed.skip_plugins)
return Config(
root=parsed.core_path.absolute(),
specific_integrations=parsed.integration_path,
action=parsed.action,
requirements=parsed.requirements,
plugins=set(parsed.plugins),
)
def main() -> int:
"""Validate manifests."""
try:
config = get_config()
except RuntimeError as err:
print(err)
return 1
plugins = [*INTEGRATION_PLUGINS]
if config.specific_integrations:
integrations = {}
for int_path in config.specific_integrations:
integration = Integration(int_path, config)
integration.load_manifest()
integrations[integration.domain] = integration
else:
integrations = Integration.load_dir(config.core_integrations_path, config)
plugins += HASS_PLUGINS
for plugin in plugins:
plugin_name = plugin.__name__.rsplit(".", maxsplit=1)[-1]
if plugin_name not in config.plugins:
continue
try:
start = monotonic()
print(f"Validating {plugin_name}...", end="", flush=True)
if (
plugin is requirements
and config.requirements
and not config.specific_integrations
):
print()
plugin.validate(integrations, config)
print(f" done in {monotonic() - start:.2f}s")
except RuntimeError as err:
print()
print()
print("Error!")
print(err)
return 1
# When we generate, all errors that are fixable will be ignored,
# as generating them will be fixed.
if config.action == "generate":
general_errors = [err for err in config.errors if not err.fixable]
invalid_itg = [
itg
for itg in integrations.values()
if any(not error.fixable for error in itg.errors)
]
else:
# action == validate
general_errors = config.errors
invalid_itg = [itg for itg in integrations.values() if itg.errors]
warnings_itg = [itg for itg in integrations.values() if itg.warnings]
print()
print("Integrations:", len(integrations))
print("Invalid integrations:", len(invalid_itg))
print()
if not invalid_itg and not general_errors:
print_integrations_status(config, warnings_itg, show_fixable_errors=False)
if config.action == "generate":
for plugin in plugins:
plugin_name = plugin.__name__.rsplit(".", maxsplit=1)[-1]
if plugin_name not in config.plugins:
continue
if hasattr(plugin, "generate"):
plugin.generate(integrations, config)
return 0
if config.action == "generate":
print("Found errors. Generating files canceled.")
print()
if general_errors:
print("General errors:")
for error in general_errors:
print("*", error)
print()
invalid_itg.extend(itg for itg in warnings_itg if itg not in invalid_itg)
print_integrations_status(config, invalid_itg, show_fixable_errors=False)
return 1
def print_integrations_status(
config: Config,
integrations: list[Integration],
*,
show_fixable_errors: bool = True,
) -> None:
"""Print integration status."""
for integration in sorted(integrations, key=attrgetter("domain")):
extra = f" - {integration.path}" if config.specific_integrations else ""
print(f"Integration {integration.domain}{extra}:")
for error in integration.errors:
if show_fixable_errors or not error.fixable:
print("*", "[ERROR]", error)
for warning in integration.warnings:
print("*", "[WARNING]", warning)
print()
if __name__ == "__main__":
sys.exit(main())