checks / unit (pull_request) Successful in 5m1s
The add-on had NO translations/ directory, so Home Assistant fell back to rendering the raw option KEYS. An operator opening Configuration read `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w` - seventy of them, in file order, with no description and no way to tell a constant derived from 1890 closed-loop test runs from their own inverter's rating. ⚠️ PRESENTATION ONLY. Not one default, not one schema range and not one line of behaviour moved. The 175 non-comment lines of config.yaml are byte-identical to release/1.0's, reordered; the only comment lines that changed are the group banners and five sentences whose "above"/"below" the reorder made false. * translations/en.yaml and translations/nl.yaml: a name and a description for all 70 options and all 7 per-entry keys of the `inverters:` repeatable section. The Dutch is written for a Flemish homeowner - afname/injectie, kwartierpiek, capaciteitstarief - not transliterated from the English. * ⭐ Every derived constant says WHERE IT CAME FROM and whether to touch it. `unit_sign_disagree_s` cites its 1890 closed-loop runs (SAFETY-30); `circulation_w` its 924 runs and the measured 400 W knee (SAFETY-31); `meter_freeze_s` its 2399 s capture (SAFETY-18); `bms_soc_floor` register 0x0560. The two numbers that are the operator's - max_w and peak_cap_w - say "THIS IS YOUR NUMBER". `cycle_cost_threshold_eur`, which is neither, says it is an open product question (OPT-08). * config.yaml reordered into task order in BOTH halves, comments carried with their keys: hardware -> meter -> ratings and limits -> tariff -> maintenance -> safety -> advanced. ⚠️ test_inverter.py:1788's trap is PRESERVED: the nested per-entry `max_w:` still precedes the flat one under `schema:`, and `inverters: []` still ships in the `options:` half. Moving the nested block below the flat one is a 1-byte edit; it was run and goes red in test_inverter.py and test_translations.py. * tools/supervisor_shape_probe.py section F MEASURES the result against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 - Supervisor's own _read_app_translations(), its own UiOptions, and the frontend's label resolution transcribed from the shipped bundle - and prints every rendered row in both languages. `all cases passed`, exit 0. * ⚠️ ONE MEASURED NEGATIVE RESULT, NOT GLOSSED: the per-entry keys inside the `inverters:` section still render RAW on frontend 20250925.1, because a list-of-objects goes to ha-selector-object and the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated - that is Supervisor's own shape for this - but this card does not claim they reach the screen. The `inverters` label and description do. * test_translations.py: a translation naming a non-existent option, or an option with no translation, fails CI - both directions, both languages, top level and per entry. Mutation-tested BOTH WAYS: eleven in-process mutations each required to NAME the key they broke, plus a negative control; six more run on disk through the purge-and-re-read protocol. Two of the eleven are the ones that cost the WHOLE FILE rather than one label - an unrecognised top-level block, and a `network:` value that is not a string. Supervisor drops a file that fails SCHEMA_APP_TRANSLATIONS with only a log warning, so those silently return the entire tab to raw keys; neither is reachable through the `configuration:` coverage, which is why they were hunted for and closed. * tools/translations_lint.py is the hand parser CI needs (no PyYAML in the add-on image) and the probe grades it, key by key and string by string, against Supervisor's own loader. It is strict and RAISES rather than skipping; nine malformed shapes prove it. Full 20-file suite green (19 existing + test_translations.py). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
239 lines
11 KiB
Python
239 lines
11 KiB
Python
"""UX-01: read config.yaml's option surface and the translations, and compare.
|
|
|
|
⚠️ WHY THIS IS A MODULE AND NOT A FUNCTION INSIDE THE CHECK. Two very different
|
|
programs have to agree about what is in `translations/*.yaml`:
|
|
|
|
* `test_translations.py`, which runs in the add-on's own CI image - Alpine,
|
|
python3 + py3-aiohttp + py3-paho-mqtt and NOTHING ELSE. There is no PyYAML
|
|
there and a check that skips on ImportError is not a check, so the parser
|
|
below is written by hand;
|
|
* `tools/supervisor_shape_probe.py`, which runs inside the real Home Assistant
|
|
Supervisor image and loads the same files with the loader Home Assistant
|
|
ACTUALLY uses (`supervisor.utils.yaml.read_json_or_yaml_file`, validated by
|
|
`SCHEMA_APP_TRANSLATIONS`).
|
|
|
|
A hand parser that quietly disagrees with the real loader is precisely the
|
|
defect this repository keeps finding - a check whose data cannot reach the
|
|
failure it names. So the probe imports THIS module and asserts, key by key and
|
|
string by string, that `parse_translation_file()` and the real loader return the
|
|
same thing. That equality is the licence for CI to use the hand parser at all.
|
|
|
|
The parser is DELIBERATELY STRICT. It accepts exactly the shape UX-01 generates
|
|
- two-space indentation, one `key:` mapping opener or one `key: "double quoted
|
|
scalar"` per line, `#` comments, blank lines - and RAISES on anything else. A
|
|
permissive parser would answer "no problems" for a file it had silently skipped
|
|
half of, which is the same failure wearing a passing grade.
|
|
"""
|
|
|
|
import io
|
|
import re
|
|
|
|
__all__ = ["parse_translation_file", "parse_translation_text", "config_surface",
|
|
"audit"]
|
|
|
|
|
|
class TranslationSyntaxError(ValueError):
|
|
"""The file is not in the subset this parser accepts. Never silently skip."""
|
|
|
|
|
|
_LINE = re.compile(r"^(?P<indent> *)(?P<name>[A-Za-z0-9_.:/-]+):(?P<rest>.*)$")
|
|
|
|
|
|
def _unescape(raw, lineno):
|
|
"""The body of a YAML double-quoted scalar -> its value."""
|
|
out, i = [], 0
|
|
while i < len(raw):
|
|
ch = raw[i]
|
|
if ch == "\\":
|
|
if i + 1 >= len(raw):
|
|
raise TranslationSyntaxError(f"line {lineno}: trailing backslash")
|
|
nxt = raw[i + 1]
|
|
if nxt not in ('"', "\\"):
|
|
# Deliberately narrow: UX-01's generator emits only these two.
|
|
# Anything else is a shape this parser has not been measured on.
|
|
raise TranslationSyntaxError(
|
|
f"line {lineno}: unsupported escape \\{nxt}")
|
|
out.append(nxt)
|
|
i += 2
|
|
continue
|
|
if ch == '"':
|
|
raise TranslationSyntaxError(f"line {lineno}: unescaped quote")
|
|
out.append(ch)
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
|
|
def parse_translation_text(text, where="<text>"):
|
|
"""A nested dict from the strict two-space YAML subset UX-01 writes.
|
|
|
|
Raises TranslationSyntaxError on ANY line it does not fully understand.
|
|
"""
|
|
root = {}
|
|
stack = [(-2, root)] # (indent, mapping)
|
|
for lineno, line in enumerate(text.splitlines(), 1):
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
if line.rstrip() != line:
|
|
raise TranslationSyntaxError(f"{where} line {lineno}: trailing whitespace")
|
|
m = _LINE.match(line)
|
|
if not m:
|
|
raise TranslationSyntaxError(f"{where} line {lineno}: not `key:` -> {line!r}")
|
|
indent, name, rest = len(m.group("indent")), m.group("name"), m.group("rest")
|
|
if indent % 2:
|
|
raise TranslationSyntaxError(
|
|
f"{where} line {lineno}: indent {indent} is not a multiple of 2")
|
|
while stack and indent <= stack[-1][0]:
|
|
stack.pop()
|
|
if not stack:
|
|
raise TranslationSyntaxError(f"{where} line {lineno}: dedented past the root")
|
|
parent_indent, parent = stack[-1]
|
|
if indent != parent_indent + 2:
|
|
raise TranslationSyntaxError(
|
|
f"{where} line {lineno}: indent {indent} does not open under "
|
|
f"indent {parent_indent}")
|
|
if name in parent:
|
|
raise TranslationSyntaxError(f"{where} line {lineno}: duplicate key {name!r}")
|
|
if rest == "":
|
|
child = {}
|
|
parent[name] = child
|
|
stack.append((indent, child))
|
|
continue
|
|
if not rest.startswith(" "):
|
|
raise TranslationSyntaxError(f"{where} line {lineno}: no space after the colon")
|
|
value = rest[1:]
|
|
if len(value) < 2 or value[0] != '"' or value[-1] != '"':
|
|
raise TranslationSyntaxError(
|
|
f"{where} line {lineno}: scalars must be double quoted -> {value!r}")
|
|
parent[name] = _unescape(value[1:-1], lineno)
|
|
return root
|
|
|
|
|
|
def parse_translation_file(path):
|
|
return parse_translation_text(io.open(path, encoding="utf-8").read(), str(path))
|
|
|
|
|
|
# --- config.yaml's option surface --------------------------------------------
|
|
# ⚠️ THE SCHEMA HALF, NOT THE OPTIONS HALF, AND THAT IS A MEASUREMENT RATHER
|
|
# THAN A PREFERENCE. Supervisor's `App.schema_ui` is `UiOptions(...)(data[
|
|
# ATTR_SCHEMA])`, so the ORDER the Configuration tab renders in is the order of
|
|
# the `schema:` block and nothing else. tools/supervisor_shape_probe.py section
|
|
# F prints that order out of the real image.
|
|
#
|
|
# ⚠️ ANCHORED ON THE INDENTATION, never on a stripped line. A top-level schema
|
|
# key is at EXACTLY two spaces and everything nested is at four or more; the
|
|
# `max_w` inside `inverters:` and the flat `max_w` are the same text otherwise,
|
|
# and config.yaml declares the nested one FIRST on purpose so that a first-match
|
|
# stripped reader is caught. See test_inverter.py part 12.
|
|
_TOP = re.compile(r"^ ([a-z_0-9]+):(.*)$")
|
|
_NESTED = re.compile(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S.*?) *$")
|
|
|
|
|
|
def config_surface(config_text):
|
|
"""(top-level schema keys in render order, {key: [nested per-entry keys]}).
|
|
|
|
Nested keys are the ones a list-of-objects option declares per entry - today
|
|
only `inverters:`. A list of scalars (`- str`) declares none, and comes back
|
|
absent from the second dict rather than as an empty list.
|
|
"""
|
|
marker = config_text.index("\nschema:")
|
|
half = config_text[marker:]
|
|
order, nested, current = [], {}, None
|
|
for line in half.splitlines():
|
|
m = _TOP.match(line)
|
|
if m:
|
|
order.append(m.group(1))
|
|
current = m.group(1) if not m.group(2).strip() else None
|
|
continue
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if current is None:
|
|
continue
|
|
n = _NESTED.match(line)
|
|
if n:
|
|
nested.setdefault(current, []).append(n.group(1))
|
|
else:
|
|
current = None
|
|
if len(order) != len(set(order)):
|
|
dupes = sorted({k for k in order if order.count(k) > 1})
|
|
raise ValueError(f"config.yaml declares {dupes} twice at the top level of schema:")
|
|
return order, nested
|
|
|
|
|
|
# --- the audit ---------------------------------------------------------------
|
|
def audit(config_text, translation, lang, require_descriptions=True):
|
|
"""Every disagreement between config.yaml and one translation file.
|
|
|
|
Returns a list of strings. EMPTY MEANS AGREEMENT; each string names the key
|
|
and says which direction the disagreement runs, because "the translations do
|
|
not match" is not something anybody can act on.
|
|
"""
|
|
order, nested = config_surface(config_text)
|
|
problems = []
|
|
top = translation.get("configuration")
|
|
if not isinstance(top, dict):
|
|
return [f"{lang}: no `configuration:` block at all"]
|
|
# ⚠️ THE WHOLE FILE IS ALL-OR-NOTHING, AND THAT IS WHY THESE TWO LINES ARE
|
|
# HERE RATHER THAN ONLY THE `configuration:` COVERAGE BELOW.
|
|
# `_read_app_translations()` runs the file through SCHEMA_APP_TRANSLATIONS
|
|
# and, on `vol.Invalid`, LOGS A WARNING AND DROPS THE WHOLE FILE - every
|
|
# label in it, not just the offending key. So a shape Supervisor refuses is
|
|
# not a small defect that costs one description; it silently returns the tab
|
|
# to raw keys. The two ways to write one that this parser can still read are
|
|
# a top-level block that is not `configuration` or `network`, and a
|
|
# `network:` block whose values are not strings ({str: str} is the schema).
|
|
for extra in sorted(set(translation) - {"configuration", "network"}):
|
|
problems.append(f"{lang}: `{extra}:` is not a block Supervisor reads "
|
|
"(only `configuration` and `network` are) - "
|
|
"SCHEMA_APP_TRANSLATIONS refuses the file and the "
|
|
"Supervisor DROPS ALL OF IT with a log warning")
|
|
for port, text in sorted((translation.get("network") or {}).items()):
|
|
if not isinstance(text, str):
|
|
problems.append(f"{lang}: `network.{port}` is not a string - "
|
|
"`network:` is {str: str} and a mapping there makes "
|
|
"Supervisor drop the WHOLE file, every label in it")
|
|
|
|
for key in order:
|
|
entry = top.get(key)
|
|
if entry is None:
|
|
problems.append(f"{lang}: option `{key}` has no translation - Home "
|
|
"Assistant would render the raw key")
|
|
continue
|
|
if not isinstance(entry, dict):
|
|
problems.append(f"{lang}: `{key}` is not a mapping")
|
|
continue
|
|
if not str(entry.get("name", "")).strip():
|
|
problems.append(f"{lang}: option `{key}` has no `name`")
|
|
if require_descriptions and not str(entry.get("description", "")).strip():
|
|
problems.append(f"{lang}: option `{key}` has no `description` - the "
|
|
"operator gets a label and no way to know whether to "
|
|
"touch it")
|
|
want = nested.get(key, [])
|
|
got = entry.get("fields")
|
|
if want and not isinstance(got, dict):
|
|
problems.append(f"{lang}: `{key}` is a repeatable section with "
|
|
f"per-entry keys {sorted(want)} and no `fields:` block")
|
|
continue
|
|
if got is not None and not want:
|
|
problems.append(f"{lang}: `{key}` carries a `fields:` block but "
|
|
"config.yaml declares no per-entry keys for it")
|
|
continue
|
|
for sub in want:
|
|
se = (got or {}).get(sub)
|
|
if se is None:
|
|
problems.append(f"{lang}: `{key}` per-entry key `{sub}` has no "
|
|
"translation")
|
|
continue
|
|
if not str(se.get("name", "")).strip():
|
|
problems.append(f"{lang}: `{key}.{sub}` has no `name`")
|
|
if require_descriptions and not str(se.get("description", "")).strip():
|
|
problems.append(f"{lang}: `{key}.{sub}` has no `description`")
|
|
for orphan in sorted(set(got or {}) - set(want)):
|
|
problems.append(f"{lang}: `{key}.{orphan}` is translated but "
|
|
"config.yaml declares no such per-entry key")
|
|
|
|
for orphan in sorted(set(top) - set(order)):
|
|
problems.append(f"{lang}: `{orphan}` is translated but config.yaml "
|
|
"declares no such option - a rename or a typo")
|
|
return problems
|