Files
Tasmota/pio-tools/compare-berry-solidify.py

414 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()