"""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 *)(?P[A-Za-z0-9_.:/-]+):(?P.*)$") 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=""): """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