mirror of
https://github.com/home-assistant/core.git
synced 2026-09-28 02:18:10 -04:00
Add pylint checker for exception translation validation (#171453)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6c8e5a8e98
commit
e8ac982e83
@@ -0,0 +1,274 @@
|
||||
"""Checker for HomeAssistantError translation usage.
|
||||
|
||||
Ensures that ``HomeAssistantError`` and its subclasses use the translation
|
||||
system (``translation_domain``, ``translation_key``) instead of hardcoded
|
||||
English strings. Also verifies that referenced translation keys exist in
|
||||
the integration's ``strings.json`` and that placeholders match.
|
||||
|
||||
- ``W7417``: Hardcoded string instead of translations (quality-scale-gated)
|
||||
- ``W7419``: Both a message string and ``translation_key`` provided
|
||||
- ``E7406``: Translation key not found in ``strings.json``
|
||||
- ``E7408``: Only one of ``translation_key``/``translation_domain`` provided
|
||||
- ``E7418``: Placeholder mismatch between code and ``strings.json``
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import astroid
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.helpers.integration import get_integration_dir
|
||||
from pylint_home_assistant.helpers.module_info import parse_module
|
||||
from pylint_home_assistant.helpers.quality_scale import quality_scale_rule_is_done
|
||||
from pylint_home_assistant.helpers.translations import (
|
||||
extract_placeholder_keys,
|
||||
get_exception_translations,
|
||||
get_exception_translations_for_domain,
|
||||
get_message_placeholders,
|
||||
)
|
||||
|
||||
_HA_ERROR_QNAME = "homeassistant.exceptions.HomeAssistantError"
|
||||
|
||||
|
||||
def _accepts_translation_key(class_node: nodes.ClassDef) -> bool:
|
||||
"""Check if a class accepts translation_key in its constructor.
|
||||
|
||||
If the class overrides ``__init__`` without a ``translation_key``
|
||||
parameter (and without ``**kwargs``), it doesn't support the
|
||||
translation system.
|
||||
"""
|
||||
for method in class_node.mymethods():
|
||||
if method.name != "__init__":
|
||||
continue
|
||||
# Class has its own __init__, check if it accepts translation_key
|
||||
if method.args.kwarg:
|
||||
return True
|
||||
return any(
|
||||
arg.name == "translation_key"
|
||||
for arg in method.args.args + method.args.kwonlyargs
|
||||
)
|
||||
# No __init__ override, inherits from parent (HomeAssistantError accepts it)
|
||||
return True
|
||||
|
||||
|
||||
def _is_ha_exception(call: nodes.Call) -> str | None:
|
||||
"""Check if a call constructs a HomeAssistantError subclass that supports translations.
|
||||
|
||||
Returns the class name if it is, None otherwise.
|
||||
"""
|
||||
try:
|
||||
for inferred in call.func.infer():
|
||||
if not isinstance(inferred, nodes.ClassDef):
|
||||
continue
|
||||
is_ha = inferred.qname() == _HA_ERROR_QNAME
|
||||
if not is_ha:
|
||||
try:
|
||||
is_ha = any(
|
||||
a.qname() == _HA_ERROR_QNAME for a in inferred.ancestors()
|
||||
)
|
||||
except astroid.exceptions.InferenceError:
|
||||
continue
|
||||
if is_ha and _accepts_translation_key(inferred):
|
||||
return str(inferred.name)
|
||||
except astroid.exceptions.InferenceError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_keyword_value(call: nodes.Call, name: str) -> nodes.NodeNG | None:
|
||||
"""Get the value of a keyword argument from a Call node."""
|
||||
for kw in call.keywords:
|
||||
if kw.arg == name:
|
||||
return kw.value
|
||||
return None
|
||||
|
||||
|
||||
def _extract_const_string(node: nodes.NodeNG | None) -> str | None:
|
||||
"""Extract a constant string value from a node."""
|
||||
if isinstance(node, nodes.Const) and isinstance(node.value, str):
|
||||
return node.value
|
||||
return None
|
||||
|
||||
|
||||
class ExceptionTranslationsChecker(BaseChecker):
|
||||
"""Checker for HomeAssistantError translation usage."""
|
||||
|
||||
name = "home_assistant_exception_translations"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"W7417": (
|
||||
"%s should use translation_domain and translation_key",
|
||||
"home-assistant-exception-not-translated",
|
||||
"Used when a HomeAssistantError subclass is raised without "
|
||||
"using the translation system.",
|
||||
),
|
||||
"E7406": (
|
||||
"Translation key '%s' not found in exceptions section of "
|
||||
"strings.json for domain '%s'",
|
||||
"home-assistant-exception-translation-key-missing",
|
||||
"Used when a HomeAssistantError references a translation_key "
|
||||
"that does not exist in the integration's strings.json.",
|
||||
),
|
||||
"W7419": (
|
||||
"%s should not pass positional arguments when translation_key is set",
|
||||
"home-assistant-exception-message-with-translation",
|
||||
"Used when a HomeAssistantError subclass passes both "
|
||||
"positional arguments and a translation_key. The translation "
|
||||
"system generates the message from the key.",
|
||||
),
|
||||
"E7408": (
|
||||
"%s must set both translation_key and translation_domain, "
|
||||
"but only one is provided",
|
||||
"home-assistant-exception-translation-key-domain-mismatch",
|
||||
"Used when a HomeAssistantError subclass sets translation_key "
|
||||
"without translation_domain or vice versa. Both are required "
|
||||
"for the translation system to generate the exception message.",
|
||||
),
|
||||
"E7418": (
|
||||
"Placeholder mismatch for translation key '%s': "
|
||||
"code passes {%s} but strings.json expects {%s}",
|
||||
"home-assistant-exception-placeholder-mismatch",
|
||||
"Used when the translation_placeholders in code don't match "
|
||||
"the placeholders in the strings.json message template.",
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_integration: bool
|
||||
_module_node: nodes.Module | None
|
||||
_domain: str | None
|
||||
_components_dir: Path | None
|
||||
_exception_translations_done: bool
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Load integration context."""
|
||||
parsed = parse_module(node.name)
|
||||
self._in_integration = parsed is not None
|
||||
self._module_node = node if parsed else None
|
||||
self._domain = parsed.domain if parsed else None
|
||||
integration_dir = get_integration_dir(node) if parsed else None
|
||||
self._components_dir = integration_dir.parent if integration_dir else None
|
||||
self._exception_translations_done = (
|
||||
parsed is not None
|
||||
and quality_scale_rule_is_done(node, "exception-translations")
|
||||
)
|
||||
|
||||
def visit_call(self, node: nodes.Call) -> None:
|
||||
"""Check HomeAssistantError raises for translation usage."""
|
||||
if not self._in_integration or self._module_node is None:
|
||||
return
|
||||
|
||||
# Must be inside a raise statement
|
||||
if not isinstance(node.parent, nodes.Raise):
|
||||
return
|
||||
|
||||
exc_name = _is_ha_exception(node)
|
||||
if exc_name is None:
|
||||
return
|
||||
|
||||
translation_key_node = _get_keyword_value(node, "translation_key")
|
||||
has_translation_key = translation_key_node is not None
|
||||
translation_key = _extract_const_string(translation_key_node)
|
||||
|
||||
# Resolve domain presence
|
||||
domain_node = _get_keyword_value(node, "translation_domain")
|
||||
has_translation_domain = domain_node is not None
|
||||
|
||||
# Case 1: Only one of translation_key/translation_domain provided
|
||||
if has_translation_key != has_translation_domain:
|
||||
self.add_message(
|
||||
"home-assistant-exception-translation-key-domain-mismatch",
|
||||
node=node,
|
||||
args=(exc_name,),
|
||||
)
|
||||
return
|
||||
|
||||
# Case 2: No translation_key at all (either hardcoded string or bare raise)
|
||||
# Only enforced when quality scale rule exception-translations is done
|
||||
if not has_translation_key:
|
||||
if self._exception_translations_done:
|
||||
self.add_message(
|
||||
"home-assistant-exception-not-translated",
|
||||
node=node,
|
||||
args=(exc_name,),
|
||||
)
|
||||
return
|
||||
|
||||
# Case 3: Both message and translation_key (message overrides translation)
|
||||
if node.args and has_translation_key:
|
||||
self.add_message(
|
||||
"home-assistant-exception-message-with-translation",
|
||||
node=node,
|
||||
args=(exc_name,),
|
||||
)
|
||||
return
|
||||
|
||||
# If no translation key or non-literal key, skip further checks
|
||||
if translation_key is None:
|
||||
return
|
||||
|
||||
# Resolve the domain value
|
||||
translation_domain = _extract_const_string(domain_node)
|
||||
if translation_domain is None:
|
||||
# Try resolving DOMAIN constant
|
||||
if isinstance(domain_node, nodes.Name) and domain_node.name == "DOMAIN":
|
||||
translation_domain = self._domain
|
||||
|
||||
if translation_domain is None:
|
||||
# Non-literal domain (variable, attribute), can't check further
|
||||
return
|
||||
|
||||
# Load translations for the target domain (may differ from current module)
|
||||
if translation_domain == self._domain:
|
||||
exception_translations = get_exception_translations(self._module_node)
|
||||
else:
|
||||
exception_translations = get_exception_translations_for_domain(
|
||||
self._module_node, translation_domain
|
||||
)
|
||||
|
||||
# Case 3: Check translation key exists
|
||||
if translation_key not in exception_translations:
|
||||
self.add_message(
|
||||
"home-assistant-exception-translation-key-missing",
|
||||
node=node,
|
||||
args=(translation_key, translation_domain),
|
||||
)
|
||||
return
|
||||
|
||||
# Case 4: Check placeholder mismatch
|
||||
entry = exception_translations[translation_key]
|
||||
message = entry.get("message", "")
|
||||
expected = get_message_placeholders(message, self._components_dir)
|
||||
|
||||
placeholder_node = _get_keyword_value(node, "translation_placeholders")
|
||||
|
||||
if placeholder_node is None:
|
||||
if not expected:
|
||||
# No placeholders expected and none provided
|
||||
return
|
||||
# No translation_placeholders keyword but strings.json expects some
|
||||
code_placeholders: set[str] = set()
|
||||
else:
|
||||
code_placeholders_or_none = extract_placeholder_keys(placeholder_node)
|
||||
if code_placeholders_or_none is None:
|
||||
# Non-literal (variable, attribute, etc.), can't check
|
||||
return
|
||||
code_placeholders = code_placeholders_or_none
|
||||
|
||||
if code_placeholders != expected:
|
||||
self.add_message(
|
||||
"home-assistant-exception-placeholder-mismatch",
|
||||
node=node,
|
||||
args=(
|
||||
translation_key,
|
||||
", ".join(sorted(code_placeholders)) or "(none)",
|
||||
", ".join(sorted(expected)) or "(none)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(ExceptionTranslationsChecker(linter))
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Helpers for reading integration translation files."""
|
||||
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import astroid
|
||||
from astroid import nodes
|
||||
import orjson
|
||||
|
||||
from .integration import get_integration_dir
|
||||
|
||||
_InferenceError = astroid.exceptions.InferenceError
|
||||
|
||||
_translations_cache: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def clear_translations_cache() -> None:
|
||||
"""Clear the translations cache (used by tests)."""
|
||||
_translations_cache.clear()
|
||||
|
||||
|
||||
def _load_translations_from_dir(integration_dir: Path) -> dict | None:
|
||||
"""Load translations from an integration directory."""
|
||||
cache_key = str(integration_dir)
|
||||
if cache_key in _translations_cache:
|
||||
return _translations_cache[cache_key]
|
||||
|
||||
# Core integrations use strings.json, custom integrations use translations/en.json
|
||||
for candidate in (
|
||||
integration_dir / "strings.json",
|
||||
integration_dir / "translations" / "en.json",
|
||||
):
|
||||
if candidate.exists():
|
||||
result: dict | None = None
|
||||
with contextlib.suppress(orjson.JSONDecodeError, OSError):
|
||||
parsed = orjson.loads(candidate.read_bytes())
|
||||
if isinstance(parsed, dict):
|
||||
result = parsed
|
||||
_translations_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
_translations_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
|
||||
def load_translations(module: nodes.Module) -> dict | None:
|
||||
"""Load and cache the translation data for the current integration.
|
||||
|
||||
For core integrations, reads ``strings.json``.
|
||||
For custom integrations, reads ``translations/en.json``.
|
||||
|
||||
Returns the parsed JSON as a dict, or ``None`` if not found.
|
||||
"""
|
||||
integration_dir = get_integration_dir(module)
|
||||
if integration_dir is None:
|
||||
return None
|
||||
return _load_translations_from_dir(integration_dir)
|
||||
|
||||
|
||||
def load_translations_for_domain(module: nodes.Module, domain: str) -> dict | None:
|
||||
"""Load translations for a specific domain.
|
||||
|
||||
Resolves the integration directory for *domain* relative to the
|
||||
current module's components root. This handles cases where
|
||||
``translation_domain`` points to a different integration than the
|
||||
one currently being linted.
|
||||
"""
|
||||
integration_dir = get_integration_dir(module)
|
||||
if integration_dir is None:
|
||||
return None
|
||||
|
||||
# Navigate to the sibling integration directory
|
||||
components_dir = integration_dir.parent
|
||||
target_dir = components_dir / domain
|
||||
if not target_dir.is_dir():
|
||||
return None
|
||||
|
||||
return _load_translations_from_dir(target_dir)
|
||||
|
||||
|
||||
def get_exception_translations(module: nodes.Module) -> dict[str, dict]:
|
||||
"""Return the ``exceptions`` section from the current integration's translations."""
|
||||
return _get_exceptions_from_data(load_translations(module))
|
||||
|
||||
|
||||
def get_exception_translations_for_domain(
|
||||
module: nodes.Module, domain: str
|
||||
) -> dict[str, dict]:
|
||||
"""Return the ``exceptions`` section for a specific domain."""
|
||||
return _get_exceptions_from_data(load_translations_for_domain(module, domain))
|
||||
|
||||
|
||||
def _get_exceptions_from_data(data: dict | None) -> dict[str, dict]:
|
||||
"""Extract the exceptions section from translation data."""
|
||||
if data is None:
|
||||
return {}
|
||||
exceptions = data.get("exceptions")
|
||||
if not isinstance(exceptions, dict):
|
||||
return {}
|
||||
return exceptions
|
||||
|
||||
|
||||
def extract_placeholder_keys(node: nodes.NodeNG | None) -> set[str] | None:
|
||||
"""Extract placeholder key names from a translation_placeholders value.
|
||||
|
||||
Handles inline dict literals directly. For variable references, uses
|
||||
astroid inference to resolve to the dict definition.
|
||||
Returns None if the value cannot be resolved.
|
||||
"""
|
||||
if node is None:
|
||||
return None
|
||||
if isinstance(node, nodes.Dict):
|
||||
return _keys_from_dict(node)
|
||||
try:
|
||||
for inferred in node.infer():
|
||||
if isinstance(inferred, nodes.Dict):
|
||||
return _keys_from_dict(inferred)
|
||||
except _InferenceError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_string_key(key: nodes.NodeNG) -> str | None:
|
||||
"""Resolve a dict key to a string value."""
|
||||
if isinstance(key, nodes.Const) and isinstance(key.value, str):
|
||||
return key.value
|
||||
# Try inference for constant references (e.g., CONF_DOMAIN)
|
||||
try:
|
||||
for inferred in key.infer():
|
||||
if isinstance(inferred, nodes.Const) and isinstance(inferred.value, str):
|
||||
return str(inferred.value)
|
||||
except _InferenceError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _keys_from_dict(node: nodes.Dict) -> set[str]:
|
||||
"""Extract string keys from a Dict node.
|
||||
|
||||
Handles literal string keys, constant references (e.g., ``CONF_DOMAIN``)
|
||||
via astroid inference, and ``**expr`` dict unpacking by inferring the
|
||||
unpacked expression.
|
||||
"""
|
||||
keys: set[str] = set()
|
||||
for key, value in node.items:
|
||||
if isinstance(key, nodes.DictUnpack):
|
||||
# Resolve the unpacked dict to extract its keys
|
||||
try:
|
||||
for inferred in value.infer():
|
||||
if isinstance(inferred, nodes.Dict):
|
||||
keys.update(_keys_from_dict(inferred))
|
||||
except _InferenceError:
|
||||
pass
|
||||
continue
|
||||
resolved = _resolve_string_key(key)
|
||||
if resolved is not None:
|
||||
keys.add(resolved)
|
||||
return keys
|
||||
|
||||
|
||||
_KEY_REF_PATTERN = re.compile(r"^\[%key:(.+)%\]$")
|
||||
|
||||
|
||||
def resolve_translation_reference(message: str, components_dir: Path | None) -> str:
|
||||
"""Resolve ``[%key:component::domain::section::key::field%]`` references.
|
||||
|
||||
Returns the resolved message string, or the original if resolution fails.
|
||||
"""
|
||||
match = _KEY_REF_PATTERN.match(message)
|
||||
if not match or components_dir is None:
|
||||
return message
|
||||
|
||||
parts = match.group(1).split("::")
|
||||
# Expected: component::domain::section::key::field
|
||||
# or: common::section::subsection::key
|
||||
if len(parts) < 3:
|
||||
return message
|
||||
|
||||
data: dict | None = None
|
||||
walk_parts: list[str] = []
|
||||
|
||||
if parts[0] == "component" and len(parts) >= 4:
|
||||
data = _load_translations_from_dir(components_dir / parts[1])
|
||||
walk_parts = parts[2:]
|
||||
elif parts[0] == "common":
|
||||
# common:: references live in homeassistant/strings.json
|
||||
data = _load_translations_from_dir(components_dir.parent)
|
||||
walk_parts = parts # walk from "common" onwards
|
||||
else:
|
||||
return message
|
||||
|
||||
if data is None:
|
||||
return message
|
||||
|
||||
current: dict | str = data
|
||||
for part in walk_parts:
|
||||
if not isinstance(current, dict):
|
||||
return message
|
||||
current = current.get(part, message)
|
||||
return str(current) if isinstance(current, str) else message
|
||||
|
||||
|
||||
def get_message_placeholders(
|
||||
message: str, components_dir: Path | None = None
|
||||
) -> set[str]:
|
||||
"""Extract placeholder names from a translation message template.
|
||||
|
||||
Resolves ``[%key:...]`` references before extracting placeholders.
|
||||
Placeholders use Python ``str.format()`` syntax: ``{name}``.
|
||||
"""
|
||||
resolved = resolve_translation_reference(message, components_dir)
|
||||
return set(re.findall(r"\{(\w+)\}", resolved))
|
||||
Reference in New Issue
Block a user