chore: drop HACS distribution and move CI to Gitea
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
This commit is contained in:
co-authored by
anthropic/claude-opus-5
parent
d6e935e8d7
commit
706580fab2
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user