The GitHub mirror is gone, so the HACS packaging and the GitHub-only CI no longer have anything to run against. Remove hacs.json and the .github/ workflow. Both of its jobs (hassfest and hacs/action) were gated on `github.server_url == 'https://github.com'` and are GitHub-hosted actions, so neither can run on the Gitea runner. Replace them with .gitea/workflows/ci.yml: - lint: ruff check + ruff format --check, version pinned because ruff's default rule set and formatter output move between releases - validate: byte-compile, then scripts/validate_integration.py, which covers the part of hassfest that matters for a manually installed custom component (manifest keys, domain/directory agreement, and translations matching strings.json key for key) README now documents manual installation only, and points at the local CI commands. manifest.json documentation and issue_tracker point at the Gitea repo; NOTICE keeps its upstream attribution. Adopting ruff surfaced two real defects, fixed here: - climate.async_set_hvac_mode raised HomeAssistantError for an unsupported mode from inside a try that catches Exception, so the message was re-wrapped as "Failed to set HVAC mode: Unsupported HVAC mode: ...". The validation is now hoisted above the try. - config_flow.async_step_reauth_confirm swallowed unexpected exceptions into a bare "unknown" error with no log, unlike the user step. It now logs via _LOGGER.exception. Remaining changes are mechanical: import ordering, docstrings on public methods, ClassVar on mutable class attributes, contextlib.suppress, and asyncio.TimeoutError -> TimeoutError (an alias since 3.11). The smoke test now reads the new DknCloudNaClient.socket_connected property rather than reaching into _socket. Co-authored-by: anthropic/claude-opus-5
191 lines
6.0 KiB
Python
Executable File
191 lines
6.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate the integration's static metadata.
|
|
|
|
This replaces the parts of Home Assistant's `hassfest` action that matter for a
|
|
manually installed custom component. hassfest itself is a GitHub-hosted action
|
|
and cannot run on the Gitea runner, and it also enforces rules that only apply
|
|
to integrations vendored into HA core.
|
|
|
|
Checks performed:
|
|
* every JSON file in the component parses
|
|
* manifest.json has the keys HA requires of a custom integration, with sane
|
|
values (domain matches the directory, version is present, etc.)
|
|
* every translation file has exactly the same key structure as strings.json,
|
|
so a missing translation shows up here rather than as a blank label in the
|
|
UI
|
|
|
|
Exits non-zero with one line per problem.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
COMPONENTS_DIR = REPO_ROOT / "custom_components"
|
|
|
|
# Keys HA reads from a custom integration's manifest. `version` is required for
|
|
# custom components specifically (core integrations must omit it).
|
|
REQUIRED_MANIFEST_KEYS = (
|
|
"domain",
|
|
"name",
|
|
"codeowners",
|
|
"documentation",
|
|
"iot_class",
|
|
"version",
|
|
)
|
|
|
|
VALID_IOT_CLASSES = {
|
|
"assumed_state",
|
|
"cloud_polling",
|
|
"cloud_push",
|
|
"local_polling",
|
|
"local_push",
|
|
"calculated",
|
|
}
|
|
|
|
VALID_INTEGRATION_TYPES = {
|
|
"device",
|
|
"entity",
|
|
"hardware",
|
|
"helper",
|
|
"hub",
|
|
"service",
|
|
"system",
|
|
"virtual",
|
|
}
|
|
|
|
|
|
def key_structure(value: object, prefix: str = "") -> set[str]:
|
|
"""Flatten a nested dict into dotted key paths, ignoring leaf values."""
|
|
if not isinstance(value, dict):
|
|
return {prefix}
|
|
paths: set[str] = set()
|
|
for key, child in value.items():
|
|
paths |= key_structure(child, f"{prefix}.{key}" if prefix else key)
|
|
return paths
|
|
|
|
|
|
def check_json_parses(errors: list[str]) -> dict[Path, object]:
|
|
"""Parse every JSON file under custom_components/, recording failures."""
|
|
parsed: dict[Path, object] = {}
|
|
for path in sorted(COMPONENTS_DIR.rglob("*.json")):
|
|
try:
|
|
parsed[path] = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as err:
|
|
errors.append(f"{path.relative_to(REPO_ROOT)}: invalid JSON: {err}")
|
|
return parsed
|
|
|
|
|
|
def check_manifest(component: Path, manifest: object, errors: list[str]) -> None:
|
|
"""Validate a single manifest.json payload."""
|
|
rel = (component / "manifest.json").relative_to(REPO_ROOT)
|
|
|
|
if not isinstance(manifest, dict):
|
|
errors.append(f"{rel}: expected a JSON object")
|
|
return
|
|
|
|
errors.extend(
|
|
f"{rel}: missing required key '{key}'"
|
|
for key in REQUIRED_MANIFEST_KEYS
|
|
if key not in manifest
|
|
)
|
|
|
|
if manifest.get("domain") != component.name:
|
|
errors.append(
|
|
f"{rel}: domain '{manifest.get('domain')}' does not match "
|
|
f"directory name '{component.name}'"
|
|
)
|
|
|
|
iot_class = manifest.get("iot_class")
|
|
if iot_class is not None and iot_class not in VALID_IOT_CLASSES:
|
|
errors.append(f"{rel}: unknown iot_class '{iot_class}'")
|
|
|
|
integration_type = manifest.get("integration_type")
|
|
if integration_type is not None and integration_type not in VALID_INTEGRATION_TYPES:
|
|
errors.append(f"{rel}: unknown integration_type '{integration_type}'")
|
|
|
|
codeowners = manifest.get("codeowners")
|
|
if not isinstance(codeowners, list):
|
|
errors.append(f"{rel}: codeowners must be a list")
|
|
else:
|
|
errors.extend(
|
|
f"{rel}: codeowner '{owner}' must start with '@'"
|
|
for owner in codeowners
|
|
if not isinstance(owner, str) or not owner.startswith("@")
|
|
)
|
|
|
|
# config_flow: true requires strings.json to describe the flow, otherwise
|
|
# the user sees untranslated keys.
|
|
if manifest.get("config_flow") and not (component / "strings.json").is_file():
|
|
errors.append(f"{rel}: config_flow is true but strings.json is missing")
|
|
|
|
|
|
def check_translations(
|
|
component: Path, parsed: dict[Path, object], errors: list[str]
|
|
) -> None:
|
|
"""Ensure each translation file mirrors strings.json exactly."""
|
|
strings_path = component / "strings.json"
|
|
if strings_path not in parsed:
|
|
return
|
|
|
|
expected = key_structure(parsed[strings_path])
|
|
translations_dir = component / "translations"
|
|
if not translations_dir.is_dir():
|
|
errors.append(
|
|
f"{component.relative_to(REPO_ROOT)}: strings.json exists but "
|
|
"translations/ is missing"
|
|
)
|
|
return
|
|
|
|
for path in sorted(translations_dir.glob("*.json")):
|
|
if path not in parsed:
|
|
continue
|
|
rel = path.relative_to(REPO_ROOT)
|
|
actual = key_structure(parsed[path])
|
|
errors.extend(
|
|
f"{rel}: missing key '{key}'" for key in sorted(expected - actual)
|
|
)
|
|
errors.extend(
|
|
f"{rel}: key '{key}' not present in strings.json"
|
|
for key in sorted(actual - expected)
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
"""Run every check and report the problems found."""
|
|
errors: list[str] = []
|
|
parsed = check_json_parses(errors)
|
|
|
|
components = sorted(p for p in COMPONENTS_DIR.iterdir() if p.is_dir())
|
|
if not components:
|
|
errors.append("custom_components/ contains no integration directory")
|
|
|
|
for component in components:
|
|
manifest_path = component / "manifest.json"
|
|
if manifest_path not in parsed:
|
|
if not manifest_path.is_file():
|
|
errors.append(
|
|
f"{component.relative_to(REPO_ROOT)}: manifest.json is missing"
|
|
)
|
|
continue
|
|
check_manifest(component, parsed[manifest_path], errors)
|
|
check_translations(component, parsed, errors)
|
|
|
|
if errors:
|
|
for error in errors:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
print(f"\n{len(errors)} problem(s) found.", file=sys.stderr)
|
|
return 1
|
|
|
|
print(
|
|
f"OK: validated {len(components)} integration(s), {len(parsed)} JSON file(s)."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|