Manual tool to compare and verify Berry solidification between C and Python (#24754)

This commit is contained in:
s-hadinger
2026-05-16 21:09:02 +02:00
committed by GitHub
parent 5bfdcca07f
commit 1ef3723300
2 changed files with 414 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
- Support for M5Stack Atom S3R drivers (#24747)
- Support for multi-byte chars like emojis (💡) in light device toggle buttons (#24482)
- Berry RGBW white blend and 10-bit gamma support to Berry LED pixel rendering (#24750)
- Manual tool to compare and verify Berry solidification between C and Python
### Breaking Changed
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env python3
"""
Compare the output of native C Berry solidification against the new
Python-port solidification.
For each Berry module under lib/libesp32{,_lvgl}/<module>/ that has both a
solidify_all.be (C) and a solidify_all_python.be (Python) script:
1. Run the native C berry binary against solidify_all.be, capturing every
generated .h file under src/solidify/ into a per-module temp dir.
2. Run "python3 -m berry_port -s -g solidify_all_python.be", capturing
every generated .h file under src/solidify/ into another temp dir.
3. Diff the two trees and report any difference (missing / extra / changed
files) per module.
The original src/solidify/ contents are preserved: we snapshot, restore, and
clean up between runs.
Usage:
python3 pio-tools/compare-berry-solidify.py # all modules
python3 pio-tools/compare-berry-solidify.py -m <name> # single module
python3 pio-tools/compare-berry-solidify.py --keep # keep temp dirs
Exit code: 0 if every module is identical, 1 if any difference is found.
"""
from __future__ import annotations
import argparse
import difflib
import filecmp
import os
import shutil
import subprocess
import sys
import tempfile
from os.path import join, isfile, isdir, basename, dirname, abspath
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
SCRIPT_DIR = dirname(abspath(__file__))
PROJECT_DIR = abspath(join(SCRIPT_DIR, ".."))
BERRY_ROOT = join(PROJECT_DIR, "lib", "libesp32", "berry")
BERRY_PORT_DIR = BERRY_ROOT # parent of berry_port package
# tasmota_defines_for_berry.be is imported only by solidify_all_python.be.
# The native C berry never sees those defines, so for a fair comparison we
# replace the file with a minimal stub during the Python run.
DEFINES_FILE = join(PROJECT_DIR, "tasmota", "tasmota_defines_for_berry.be")
DEFINES_STUB = (
"# Empty stub used by compare-berry-solidify.py to neutralize\n"
"# tasmota_defines_for_berry.be while diffing native vs. python output.\n"
"import preproc\n"
"preproc.clear()\n"
)
# Module dirs to compare (path is relative to PROJECT_DIR)
MODULES = [
join("lib", "libesp32", "berry_tasmota"),
join("lib", "libesp32", "berry_matter"),
join("lib", "libesp32", "berry_animation"),
join("lib", "libesp32", "berry_custom"),
join("lib", "libesp32_lvgl", "lv_binding_berry"),
join("lib", "libesp32_lvgl", "lv_haspmota"),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _native_berry_binary() -> str:
"""Return the native Berry binary appropriate for the host."""
if sys.platform == "win32":
bin_path = join(BERRY_ROOT, "berry.exe")
else:
bin_path = join(BERRY_ROOT, "berry")
if not isfile(bin_path):
sys.exit(
f"error: native Berry binary not found at {bin_path}\n"
f" build it first with `make -C {BERRY_ROOT}`"
)
if not os.access(bin_path, os.X_OK):
sys.exit(f"error: {bin_path} is not executable")
return bin_path
def _snapshot_dir(src: str) -> str | None:
"""Move the contents of src to a fresh temp dir, return the temp dir.
Returns None if src does not exist."""
if not isdir(src):
return None
tmp = tempfile.mkdtemp(prefix="solidify_backup_")
for fn in os.listdir(src):
shutil.move(join(src, fn), join(tmp, fn))
return tmp
def _restore_dir(src: str, backup: str | None) -> None:
"""Restore a previously snapshotted directory (and remove the backup)."""
if backup is None:
return
# clear src first
if isdir(src):
for fn in os.listdir(src):
p = join(src, fn)
if os.path.isdir(p):
shutil.rmtree(p)
else:
os.remove(p)
else:
os.makedirs(src, exist_ok=True)
for fn in os.listdir(backup):
shutil.move(join(backup, fn), join(src, fn))
shutil.rmtree(backup, ignore_errors=True)
def _clear_dir(d: str) -> None:
"""Empty (but keep) a directory."""
if not isdir(d):
os.makedirs(d, exist_ok=True)
return
for fn in os.listdir(d):
p = join(d, fn)
if os.path.isdir(p):
shutil.rmtree(p)
else:
os.remove(p)
def _copy_dir(src: str, dst: str) -> None:
"""Recursively copy src -> dst (dst must not exist)."""
shutil.copytree(src, dst)
class _StubDefines:
"""Context manager: replace tasmota_defines_for_berry.be with an empty
stub for the duration of the with-block, restoring the original on exit
(even on exception)."""
def __init__(self, target: str, stub_content: str):
self.target = target
self.stub_content = stub_content
self.backup = None # type: str | None
def __enter__(self):
if isfile(self.target):
fd, self.backup = tempfile.mkstemp(prefix="defines_backup_",
suffix=".be")
os.close(fd)
shutil.copy2(self.target, self.backup)
else:
self.backup = None
os.makedirs(dirname(self.target), exist_ok=True)
with open(self.target, "w", encoding="utf-8") as f:
f.write(self.stub_content)
return self
def __exit__(self, exc_type, exc, tb):
try:
if self.backup is not None:
shutil.copy2(self.backup, self.target)
os.remove(self.backup)
else:
# original did not exist remove stub we created
if isfile(self.target):
os.remove(self.target)
except OSError as e:
print(f"WARN: could not restore {self.target}: {e}",
file=sys.stderr)
return False # never swallow exceptions
def _filter_chatter(line: str) -> bool:
"""Return True if the line is routine chatter that should be silenced."""
s = line.strip()
if s.startswith("Parsing:"):
return True
if s.startswith("Skipping:"):
return True
if s.startswith("# Output directory"):
return True
return False
def _run(cmd: list[str], cwd: str, env: dict[str, str], label: str) -> int:
"""Run a subprocess, streaming filtered output. Returns exit code."""
print(f" [{label}] $ {' '.join(cmd)}")
proc = subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
other = []
assert proc.stdout is not None
for raw in proc.stdout:
line = raw.rstrip("\r\n")
if _filter_chatter(line):
continue
other.append(line)
rc = proc.wait()
for line in other:
if line:
print(f" [{label}] {line}")
return rc
# ---------------------------------------------------------------------------
# Solidification runners
# ---------------------------------------------------------------------------
def run_native(module_dir: str, out_dir: str, native_bin: str) -> bool:
"""Run the native C berry against solidify_all.be. Return True on success."""
script = join(module_dir, "solidify_all.be")
if not isfile(script):
print(f" [native] no solidify_all.be in {module_dir} skipping")
return False
solidify_dir = join(module_dir, "src", "solidify")
os.makedirs(solidify_dir, exist_ok=True)
env = os.environ.copy()
env["PYTHONUTF8"] = "1" # harmless for the C binary
rc = _run([native_bin, "-s", "-g", "solidify_all.be"],
cwd=module_dir, env=env, label="native")
if rc != 0:
print(f" [native] FAILED (rc={rc})")
return False
# snapshot output
if isdir(out_dir):
shutil.rmtree(out_dir)
_copy_dir(solidify_dir, out_dir)
return True
def run_python(module_dir: str, out_dir: str) -> bool:
"""Run the Python port against solidify_all_python.be. Return True on success."""
script = join(module_dir, "solidify_all_python.be")
if not isfile(script):
print(f" [python] no solidify_all_python.be in {module_dir} skipping")
return False
solidify_dir = join(module_dir, "src", "solidify")
os.makedirs(solidify_dir, exist_ok=True)
env = os.environ.copy()
existing_pp = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
BERRY_PORT_DIR + (os.pathsep + existing_pp if existing_pp else "")
)
env["PYTHONUTF8"] = "1"
rc = _run([sys.executable, "-m", "berry_port", "-s", "-g",
"solidify_all_python.be"],
cwd=module_dir, env=env, label="python")
if rc != 0:
print(f" [python] FAILED (rc={rc})")
return False
if isdir(out_dir):
shutil.rmtree(out_dir)
_copy_dir(solidify_dir, out_dir)
return True
# ---------------------------------------------------------------------------
# Diff logic
# ---------------------------------------------------------------------------
def _list_h(d: str) -> set[str]:
if not isdir(d):
return set()
return {fn for fn in os.listdir(d) if fn.endswith(".h")}
def diff_trees(native_dir: str, python_dir: str, max_diff_lines: int = 60) -> bool:
"""Compare two trees of .h files. Returns True if identical."""
a = _list_h(native_dir)
b = _list_h(python_dir)
only_native = sorted(a - b)
only_python = sorted(b - a)
common = sorted(a & b)
differing = []
for fn in common:
if not filecmp.cmp(join(native_dir, fn), join(python_dir, fn),
shallow=False):
differing.append(fn)
identical = (not only_native and not only_python and not differing)
print(f" files: native={len(a)}, python={len(b)}, common={len(common)}, "
f"differing={len(differing)}")
if only_native:
print(f" only in native ({len(only_native)}): "
f"{', '.join(only_native[:10])}"
+ (" ..." if len(only_native) > 10 else ""))
if only_python:
print(f" only in python ({len(only_python)}): "
f"{', '.join(only_python[:10])}"
+ (" ..." if len(only_python) > 10 else ""))
for fn in differing:
print(f" DIFF: {fn}")
with open(join(native_dir, fn), "r", errors="replace") as f:
a_lines = f.readlines()
with open(join(python_dir, fn), "r", errors="replace") as f:
b_lines = f.readlines()
diff = list(difflib.unified_diff(
a_lines, b_lines,
fromfile=f"native/{fn}",
tofile=f"python/{fn}",
n=2,
))
if len(diff) > max_diff_lines:
for line in diff[:max_diff_lines]:
sys.stdout.write(" " + line)
print(f" ... ({len(diff) - max_diff_lines} more lines)")
else:
for line in diff:
sys.stdout.write(" " + line)
return identical
# ---------------------------------------------------------------------------
# Per-module driver
# ---------------------------------------------------------------------------
def compare_module(rel_module: str, work_root: str, native_bin: str) -> bool:
"""Run both solidifiers for one module and diff results. Returns True
if the trees are identical."""
module_dir = join(PROJECT_DIR, rel_module)
solidify_dir = join(module_dir, "src", "solidify")
print(f"\n=== {rel_module} ===")
native_out = join(work_root, basename(rel_module) + "__native")
python_out = join(work_root, basename(rel_module) + "__python")
backup = _snapshot_dir(solidify_dir)
try:
# ensure a clean output dir for the native run
_clear_dir(solidify_dir)
ok_n = run_native(module_dir, native_out, native_bin)
# ensure a clean output dir for the python run
_clear_dir(solidify_dir)
# Neutralize tasmota_defines_for_berry.be so the Python run sees no
# preproc defines (matching what the native C berry sees, since it
# doesn't import that file at all).
with _StubDefines(DEFINES_FILE, DEFINES_STUB):
ok_p = run_python(module_dir, python_out)
finally:
_restore_dir(solidify_dir, backup)
if not ok_n or not ok_p:
return False
return diff_trees(native_out, python_out)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="Compare native C and Python-port Berry solidification.")
p.add_argument("-m", "--module", action="append",
help="restrict to a module (basename, may be repeated). "
"Default: all known modules.")
p.add_argument("--keep", action="store_true",
help="keep the temp directory with both output trees.")
args = p.parse_args()
selected = MODULES
if args.module:
wanted = set(args.module)
selected = [m for m in MODULES if basename(m) in wanted]
if not selected:
sys.exit(f"error: none of {sorted(wanted)} matches known modules: "
f"{[basename(m) for m in MODULES]}")
native_bin = _native_berry_binary()
print(f"Native berry: {native_bin}")
print(f"Python: {sys.executable}")
work_root = tempfile.mkdtemp(prefix="solidify_compare_")
print(f"Workdir: {work_root}")
overall_ok = True
summary = []
for rel in selected:
ok = compare_module(rel, work_root, native_bin)
summary.append((rel, ok))
if not ok:
overall_ok = False
print("\n=== Summary ===")
for rel, ok in summary:
print(f" {'OK ' if ok else 'DIFFER'} {rel}")
if args.keep:
print(f"\nKept output trees in: {work_root}")
else:
shutil.rmtree(work_root, ignore_errors=True)
sys.exit(0 if overall_ok else 1)
if __name__ == "__main__":
main()