Compare commits

...

7 Commits

Author SHA1 Message Date
epenet 0691c950d8 Merge branch 'dev' into epenet/20260604-1140 2026-06-08 16:58:10 +02:00
epenet 3bcd936d94 Adjust nx584 2026-06-04 12:28:22 +00:00
epenet 2ea1f5a3bd Merge remote-tracking branch 'origin/dev' into epenet/20260604-1140 2026-06-04 12:03:00 +00:00
epenet 93f6153fb4 Merge remote-tracking branch 'origin/dev' into epenet/20260604-1140 2026-06-04 11:23:10 +00:00
epenet fc879d62e8 Merge branch 'dev' into epenet/20260604-1140 2026-06-04 12:49:30 +02:00
epenet 966090fc72 Merge branch 'dev' into epenet/20260604-1140 2026-06-04 12:37:24 +02:00
epenet 05c677b6ce Add component domain validation to pylint domain constant checker 2026-06-04 09:41:00 +00:00
6 changed files with 48 additions and 16 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Constants for NX584 alarm control panels."""
DOMAIN = "nx584"
@@ -0,0 +1,3 @@
"""Constants for the universal component."""
DOMAIN = "universal"
@@ -6,7 +6,7 @@ from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.helpers.module_info import is_test_module
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
@dataclass
@@ -34,7 +34,9 @@ _DOMAIN_CONSTANTS: set[str] = {"DOMAIN", "domain"}
_DOMAIN_SUFFIXES: tuple[str, ...] = ("_DOMAIN", "_domain")
def _check_call_node_domain_arguments(node: nodes.Call) -> nodes.NodeNG | None:
def _check_call_node_domain_arguments(
node: nodes.Call, component_domain: str | None
) -> nodes.NodeNG | None:
"""Ensure the call node arguments are valid domain constant or variable.
Return None if the argument node is valid, or the argument node if it is invalid.
@@ -46,16 +48,20 @@ def _check_call_node_domain_arguments(node: nodes.Call) -> nodes.NodeNG | None:
node.func.attrname == method_name
and node.func.expr.as_string() == method_source
):
return _check_call_node_domain_argument(node, arg_info)
return _check_call_node_domain_argument(
node, component_domain, arg_info
)
case nodes.Name():
for func_name, arg_info in _FUNCTION_CHECKS:
if node.func.name == func_name:
return _check_call_node_domain_argument(node, arg_info)
return _check_call_node_domain_argument(
node, component_domain, arg_info
)
return None
def _check_call_node_domain_argument(
call_node: nodes.Call, arg_info: ArgumentCheckInfo
call_node: nodes.Call, component_domain: str | None, arg_info: ArgumentCheckInfo
) -> nodes.NodeNG | None:
"""Ensure the argument node is a domain constant or variable.
@@ -74,14 +80,16 @@ def _check_call_node_domain_argument(
)
if argument_node and not _check_domain_argument(
argument_node, arg_info.allow_iterable
argument_node, component_domain, arg_info.allow_iterable
):
return argument_node
return None
def _check_domain_argument(arg_node: nodes.NodeNG, allow_iterable: bool) -> bool:
def _check_domain_argument(
arg_node: nodes.NodeNG, component_domain: str | None, allow_iterable: bool
) -> bool:
"""Ensure the argument node is a domain constant or variable.
We allow:
@@ -99,7 +107,7 @@ def _check_domain_argument(arg_node: nodes.NodeNG, allow_iterable: bool) -> bool
) in _DOMAIN_CONSTANTS or attrname.endswith(_DOMAIN_SUFFIXES):
return True
case nodes.Const():
if isinstance(arg_node.value, str):
if isinstance(arg_node.value, str) and arg_node.value != component_domain:
return True
case nodes.Name():
if (node_name := arg_node.name) in _DOMAIN_CONSTANTS or node_name.endswith(
@@ -112,7 +120,9 @@ def _check_domain_argument(arg_node: nodes.NodeNG, allow_iterable: bool) -> bool
case nodes.Tuple():
if allow_iterable:
return all(
_check_domain_argument(element, allow_iterable=False)
_check_domain_argument(
element, component_domain, allow_iterable=False
)
for element in arg_node.elts
)
@@ -135,9 +145,13 @@ class DomainConstantChecker(BaseChecker):
options = ()
_in_test_module: bool
_component_domain: str | None
def visit_module(self, node: nodes.Module) -> None:
"""Visit Module node."""
self._component_domain = None
if parsed := parse_module(node.name, include_test=True):
self._component_domain = parsed.domain
self._in_test_module = is_test_module(node.name)
def visit_call(self, node: nodes.Call) -> None:
@@ -145,7 +159,9 @@ class DomainConstantChecker(BaseChecker):
if not self._in_test_module:
return
if invalid_arg_node := _check_call_node_domain_arguments(node):
if invalid_arg_node := _check_call_node_domain_arguments(
node, self._component_domain
):
self.add_message(
"home-assistant-domain-argument",
node=invalid_arg_node,
@@ -5,6 +5,8 @@ import re
_INTEGRATION_ROOT = "homeassistant.components"
_INTEGRATION_ROOT_DOT = f"{_INTEGRATION_ROOT}."
_INTEGRATION_TEST_ROOT = "tests.components"
_INTEGRATION_TEST_ROOT_DOT = f"{_INTEGRATION_TEST_ROOT}."
_ROOT_SEGMENT_COUNT = _INTEGRATION_ROOT.count(".") + 1
_MODULE_REGEX: re.Pattern[str] = re.compile(
rf"^{re.escape(_INTEGRATION_ROOT)}\.\w+(\.\w+)?$"
@@ -26,14 +28,20 @@ class IntegrationModule:
"""
def parse_module(module_name: str) -> IntegrationModule | None:
def parse_module(
module_name: str, *, include_test: bool = False
) -> IntegrationModule | None:
"""Parse a dotted module name into integration parts.
Returns ``None`` if *module_name* is not under the integration root.
For deep sub-modules (e.g. ``homeassistant.components.hue.light.v2``),
``module`` is set to the first segment after the domain (``light``).
"""
if not module_name.startswith(_INTEGRATION_ROOT_DOT):
if module_name.startswith(_INTEGRATION_ROOT_DOT):
root = _INTEGRATION_ROOT
elif include_test and module_name.startswith(_INTEGRATION_TEST_ROOT_DOT):
root = _INTEGRATION_TEST_ROOT
else:
return None
parts = module_name.split(".")
@@ -42,13 +50,13 @@ def parse_module(module_name: str) -> IntegrationModule | None:
return None
if n == _ROOT_SEGMENT_COUNT + 1:
return IntegrationModule(
root=_INTEGRATION_ROOT,
root=root,
domain=parts[_ROOT_SEGMENT_COUNT],
module=None,
)
# n >= _ROOT_SEGMENT_COUNT + 2: domain.module[.submodule...]
return IntegrationModule(
root=_INTEGRATION_ROOT,
root=root,
domain=parts[_ROOT_SEGMENT_COUNT],
module=parts[_ROOT_SEGMENT_COUNT + 1],
)
+2 -1
View File
@@ -8,6 +8,7 @@ import pytest
import requests
from homeassistant.components.nx584 import binary_sensor as nx584
from homeassistant.components.nx584.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -104,7 +105,7 @@ async def _test_assert_graceful_fail(
hass: HomeAssistant, config: dict[str, Any]
) -> None:
"""Test the failing."""
assert not await async_setup_component(hass, "nx584", config)
assert not await async_setup_component(hass, DOMAIN, config)
@pytest.mark.usefixtures("client")
@@ -14,6 +14,7 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
)
from homeassistant.components.universal import media_player as universal
from homeassistant.components.universal.const import DOMAIN
from homeassistant.const import (
SERVICE_RELOAD,
STATE_OFF,
@@ -1394,7 +1395,7 @@ async def test_reload(hass: HomeAssistant) -> None:
yaml_path = get_fixture_path("configuration.yaml", "universal")
with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
await hass.services.async_call(
"universal",
DOMAIN,
SERVICE_RELOAD,
{},
blocking=True,