#!/usr/bin/env python3 """Canonical YAML normalization used by every HA import path. One function, one style, used everywhere data lands in the repo (see services/home-assistant/DESIGN.md, "Canonical format"): block collections, indent width 2, unlimited line width, sorted keys, UTF-8. HA configs use custom `!`-prefixed tags (`!include`, `!secret`, `!env_var`, `!input`, the `!include_dir_*` family, and any future addition) that `yaml.safe_load` has no constructor for and rejects with a ConstructorError. These tags are opaque to us — we never need to resolve `!include`, we just need to carry it through a parse/dump round-trip unchanged. `TaggedValue` plus the loader/dumper hooks below handle any `!xxx` tag generically (scalar, sequence, or mapping payload) without enumerating known tag names. """ import json import sys import yaml class TaggedValue: """Wraps a value parsed from a custom `!tag` node (see module docstring). `tag` is the full tag text (e.g. `"!include"`); `value` is the plain Python object (str/list/dict) constructed from the node's payload. """ def __init__(self, tag, value): self.tag = tag self.value = value def __eq__(self, other): return ( isinstance(other, TaggedValue) and self.tag == other.tag and self.value == other.value ) def __repr__(self): return f"TaggedValue({self.tag!r}, {self.value!r})" class _CanonicalLoader(yaml.SafeLoader): pass def _construct_tagged_value(loader, tag_suffix, node): tag = "!" + tag_suffix if isinstance(node, yaml.ScalarNode): value = loader.construct_scalar(node) elif isinstance(node, yaml.SequenceNode): value = loader.construct_sequence(node, deep=True) elif isinstance(node, yaml.MappingNode): value = loader.construct_mapping(node, deep=True) else: raise yaml.constructor.ConstructorError( None, None, f"unsupported node type for tag {tag!r}", node.start_mark ) return TaggedValue(tag, value) _CanonicalLoader.add_multi_constructor("!", _construct_tagged_value) class _CanonicalDumper(yaml.SafeDumper): pass def _represent_none(dumper, _value): return dumper.represent_scalar("tag:yaml.org,2002:null", "") def _represent_tagged_value(dumper, data): if isinstance(data.value, dict): return dumper.represent_mapping(data.tag, data.value) if isinstance(data.value, list): return dumper.represent_sequence(data.tag, data.value) return dumper.represent_scalar(data.tag, data.value) _CanonicalDumper.add_representer(type(None), _represent_none) _CanonicalDumper.add_representer(TaggedValue, _represent_tagged_value) def dump_canonical_yaml(data): """Render `data` as canonical YAML text.""" return yaml.dump( data, Dumper=_CanonicalDumper, default_flow_style=False, sort_keys=True, allow_unicode=True, width=1 << 31, indent=2, ) def parse_yaml_text(text): """Parse YAML text, resolving HA's custom `!include` / `!secret` / etc. tags to `TaggedValue` instances instead of raising ConstructorError (what plain `yaml.safe_load` does). """ return yaml.load(text, Loader=_CanonicalLoader) def normalize_yaml_text(text): """Parse YAML text then re-dump it through the canonical formatter.""" data = parse_yaml_text(text) return dump_canonical_yaml(data) def normalize_json_text(text): """Parse JSON text (e.g. a `.storage/*` file) and dump as canonical YAML.""" data = json.loads(text) return dump_canonical_yaml(data) def main(argv): if len(argv) != 3 or argv[1] not in ("yaml", "json"): print("usage: normalize.py ", file=sys.stderr) return 2 mode, path = argv[1], argv[2] with open(path, "r", encoding="utf-8") as f: text = f.read() out = normalize_yaml_text(text) if mode == "yaml" else normalize_json_text(text) sys.stdout.write(out) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv))