Files
github-actions[bot] f9fcc7adab sunnypilot v2026.002.000 release
date: 2026-06-28T09:48:35
master commit: da6313dbe95b3f24bb5d8018b0e5f950f5823ca7
2026-06-28 09:49:29 +08:00

212 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
Scans all per-page YAML files in settings_ui_src/pages/ and replaces inlined
rule blocks that exactly match a macro definition in _macros.yaml with $ref
references.
Match policy:
- Whole-list match (exact): replace the entire list with [{$ref}].
- Single-rule match: any individual rule whose canonical form equals macro[0]
(when macro is exactly one rule) is replaced with {$ref}.
Re-run is idempotent (already-substituted $refs are skipped).
Usage:
python apply_macros.py [--src DIR] [--dry-run]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import yaml
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
class _BlockDumper(yaml.SafeDumper):
pass
def _represent_dict(dumper, data):
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
def _represent_list(dumper, data):
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
_BlockDumper.add_representer(dict, _represent_dict)
_BlockDumper.add_representer(list, _represent_list)
def _dump_yaml(data) -> str:
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
def _canon(node) -> str:
return json.dumps(node, sort_keys=True, separators=(",", ":"))
def _is_ref(node) -> bool:
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
def _make_ref(name: str) -> dict:
return {"$ref": f"#/macros/{name}"}
def _substitute_rules(rules: list, whole_list: dict, single_rule: dict) -> list:
if not isinstance(rules, list) or not rules:
return rules
full_canon = _canon(rules)
if full_canon in whole_list:
return [_make_ref(whole_list[full_canon])]
out = []
for r in rules:
if _is_ref(r):
out.append(r)
continue
rc = _canon(r)
if rc in single_rule:
out.append(_make_ref(single_rule[rc]))
else:
out.append(r)
return out
def _walk_item(item: dict, whole_list: dict, single_rule: dict) -> bool:
changed = False
for ctx in ("visibility", "enablement"):
if ctx in item:
new = _substitute_rules(item[ctx], whole_list, single_rule)
if new != item[ctx]:
item[ctx] = new
changed = True
if "options" in item:
for opt in item["options"]:
if not isinstance(opt, dict):
continue
for ctx in ("visibility", "enablement"):
if ctx in opt:
new = _substitute_rules(opt[ctx], whole_list, single_rule)
if new != opt[ctx]:
opt[ctx] = new
changed = True
if "sub_items" in item:
for sub in item["sub_items"]:
if _walk_item(sub, whole_list, single_rule):
changed = True
return changed
def _walk_page(page: dict, whole_list: dict, single_rule: dict) -> bool:
changed = False
for sec in page.get("sections", []) or []:
for ctx in ("visibility", "enablement"):
if ctx in sec:
new = _substitute_rules(sec[ctx], whole_list, single_rule)
if new != sec[ctx]:
sec[ctx] = new
changed = True
for it in sec.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
for sp in sec.get("sub_panels", []) or []:
for it in sp.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
for it in page.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
for sp in page.get("sub_panels", []) or []:
for it in sp.get("items", []) or []:
if _walk_item(it, whole_list, single_rule):
changed = True
return changed
def _build_macro_indices(macros: dict) -> tuple[dict, dict]:
whole_list = {}
single_rule = {}
for name, body in macros.items():
if isinstance(body, list):
whole_list[_canon(body)] = name
if len(body) == 1 and isinstance(body[0], dict):
single_rule[_canon(body[0])] = name
elif isinstance(body, dict):
single_rule[_canon(body)] = name
return whole_list, single_rule
def _read_yaml_with_header(path: str) -> tuple[list[str], dict]:
with open(path) as f:
text = f.read()
header_lines = []
for line in text.splitlines(keepends=True):
if line.startswith("#"):
header_lines.append(line)
elif line.strip() == "":
header_lines.append(line)
else:
break
doc = yaml.safe_load(text) or {}
return header_lines, doc
def _write_yaml_with_header(path: str, header: list[str], doc: dict) -> None:
with open(path, "w") as f:
if header:
# Keep at most one trailing blank line in header
while len(header) > 1 and header[-1].strip() == "" and header[-2].strip() == "":
header.pop()
for line in header:
f.write(line)
f.write(_dump_yaml(doc))
def apply(src_dir: str, dry_run: bool) -> int:
with open(os.path.join(src_dir, "_macros.yaml")) as f:
macros_doc = yaml.safe_load(f) or {}
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
whole_list, single_rule = _build_macro_indices(macros)
changed_files = 0
pages_dir = os.path.join(src_dir, "pages")
if os.path.isdir(pages_dir):
for fn in sorted(os.listdir(pages_dir)):
if not fn.endswith((".yaml", ".yml")) or fn.startswith("_"):
continue
path = os.path.join(pages_dir, fn)
header, doc = _read_yaml_with_header(path)
if _walk_page(doc, whole_list, single_rule):
changed_files += 1
if not dry_run:
_write_yaml_with_header(path, header, doc)
else:
print(f"would-rewrite: {path}")
print(f"{'Would rewrite' if dry_run else 'Rewrote'} {changed_files} files")
return 0
def _main() -> int:
parser = argparse.ArgumentParser(description="Substitute inlined rule blocks with $ref macros across pages/.")
parser.add_argument("--src", default=DEFAULT_SRC)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
return apply(args.src, args.dry_run)
if __name__ == "__main__":
sys.exit(_main())