diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 3e979b1d8ce..6bd7f2cc3b5 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -21,6 +21,7 @@ from . import ( docker, icons, integration_info, + integration_type, json, labs, manifest, @@ -48,6 +49,7 @@ INTEGRATION_PLUGINS = [ dhcp, icons, integration_info, + integration_type, json, labs, manifest, diff --git a/script/hassfest/integration_type.py b/script/hassfest/integration_type.py new file mode 100644 index 00000000000..e1028ae56f7 --- /dev/null +++ b/script/hassfest/integration_type.py @@ -0,0 +1,100 @@ +"""Validate integration type is set for config flow integrations.""" + +from __future__ import annotations + +from .model import Config, Integration + +# Integrations with config_flow that are missing integration_type. +# These need to be fixed; do not add new entries to this list. +MISSING_INTEGRATION_TYPE = { + "abode", + "acmeda", + "adax", + "awair", + "bluetooth", + "bthome", + "chacon_dio", + "color_extractor", + "crownstone", + "deako", + "dialogflow", + "dynalite", + "elmax", + "emulated_roku", + "ezviz", + "file", + "filesize", + "fluss", + "flux_led", + "folder_watcher", + "forked_daapd", + "geniushub", + "gentex_homelink", + "geofency", + "govee_light_local", + "gpsd", + "gpslogger", + "gree", + "holiday", + "homekit", + "html5", + "ifttt", + "influxdb", + "ios", + "jewish_calendar", + "local_calendar", + "local_file", + "local_ip", + "local_todo", + "locative", + "mcp", + "media_extractor", + "mill", + "mjpeg", + "modern_forms", + "ness_alarm", + "nmap_tracker", + "otp", + "orvibo", + "profiler", + "proximity", + "rhasspy", + "risco", + "rpi_power", + "scrape", + "shopping_list", + "sql", + "sunweg", + "systemmonitor", + "tasmota", + "traccar", + "traccar_server", + "upb", + "version", + "volvooncall", + "wemo", + "zodiac", +} + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate that all config flow integrations declare an integration type.""" + for integration in integrations.values(): + if not integration.config_flow or not integration.core: + continue + + if "integration_type" in integration.manifest: + if integration.domain in MISSING_INTEGRATION_TYPE: + integration.add_error( + "integration_type", + "Integration has an `integration_type` in the manifest but is still listed in MISSING_INTEGRATION_TYPE", + ) + continue + + if integration.domain in MISSING_INTEGRATION_TYPE: + continue + + integration.add_error( + "integration_type", + "Integration has a config flow but is missing an `integration_type` in the manifest", + ) diff --git a/tests/hassfest/test_integration_type.py b/tests/hassfest/test_integration_type.py new file mode 100644 index 00000000000..1fdfa096412 --- /dev/null +++ b/tests/hassfest/test_integration_type.py @@ -0,0 +1,92 @@ +"""Tests for hassfest integration_type.""" + +import pytest + +from script.hassfest import integration_type +from script.hassfest.model import Config, Integration + +from . import get_integration + + +def _get_integration(domain: str, config: Config, manifest_extra: dict) -> Integration: + """Helper to create an integration with extra manifest keys.""" + integration = get_integration(domain, config) + integration.manifest.update(manifest_extra) + return integration + + +@pytest.mark.usefixtures("mock_core_integration") +def test_integration_with_config_flow_and_integration_type(config: Config) -> None: + """Integration with config_flow and integration_type should pass without errors.""" + integrations = { + "test": _get_integration( + "test", + config, + {"config_flow": True, "integration_type": "device"}, + ) + } + integration_type.validate(integrations, config) + assert integrations["test"].errors == [] + + +@pytest.mark.usefixtures("mock_core_integration") +def test_integration_with_config_flow_missing_integration_type(config: Config) -> None: + """Integration with config_flow but no integration_type and not in allowlist should error.""" + integrations = { + "test": _get_integration( + "test", + config, + {"config_flow": True}, + ) + } + integration_type.validate(integrations, config) + assert len(integrations["test"].errors) == 1 + assert "missing an `integration_type`" in integrations["test"].errors[0].error + + +@pytest.mark.usefixtures("mock_core_integration") +def test_integration_with_config_flow_in_allowlist(config: Config) -> None: + """Integration with config_flow but no integration_type and in allowlist should pass.""" + domain = next(iter(integration_type.MISSING_INTEGRATION_TYPE)) + integrations = { + domain: _get_integration( + domain, + config, + {"config_flow": True}, + ) + } + integration_type.validate(integrations, config) + assert integrations[domain].errors == [] + + +@pytest.mark.usefixtures("mock_core_integration") +def test_integration_with_integration_type_still_in_allowlist(config: Config) -> None: + """Integration with integration_type but still in allowlist should error.""" + domain = next(iter(integration_type.MISSING_INTEGRATION_TYPE)) + integrations = { + domain: _get_integration( + domain, + config, + {"config_flow": True, "integration_type": "device"}, + ) + } + integration_type.validate(integrations, config) + assert len(integrations[domain].errors) == 1 + assert ( + "still listed in MISSING_INTEGRATION_TYPE" + in integrations[domain].errors[0].error + ) + + +@pytest.mark.usefixtures("mock_core_integration") +def test_integration_without_config_flow_skipped(config: Config) -> None: + """Integration without config_flow should be skipped regardless of integration_type.""" + integrations = { + "test": _get_integration( + "test", + config, + {}, + ) + } + integration_type.validate(integrations, config) + assert integrations["test"].errors == []