master
John Lauer v0.9.109: kicad_export_part (one-off single-part library exports: symbol-only .kicad_sym, footprint-only .pretty, bare… 9d49110 2mo ago
"""uninstall.py — remove everything this bridge ever created on the machine.

Implements the Bridge SDK uninstall contract (kicad-bridge issue #18, ab 2.0.28):
Adom Bridge's uninstaller CASCADES — it calls each bridge's `<prefix>uninstall`
before removing itself — so this verb must leave nothing of ours behind.

Contract requirements honored here:
- IDEMPOTENT + NON-INTERACTIVE: safe when already clean; never prompts, never
  blocks; everything goes to the log/report, not a dialog.
- BEST-EFFORT PER ITEM: one locked file doesn't abort the rest; the report says
  exactly what was and wasn't removed.
- SCOPED TO OUR OWN ARTIFACTS: never touches KiCad itself, other bridges, or
  the user's own projects/data.

What this bridge creates (and this verb removes):
  1. Plugin payload  — adom_bridge.py + usercustomize.py (+ their __pycache__)
     in Documents/KiCad/<ver>/3rdparty/Python311/site-packages/
  2. Adom symbol lib — Documents/KiCad/<ver>/symbols/Adom.kicad_sym + its
     "Adom" row in %APPDATA%/kicad/<ver>/sym-lib-table
  3. Adom footprints — Documents/KiCad/<ver>/footprints/Adom.pretty/ + its
     "Adom" row in fp-lib-table + Documents/KiCad/<ver>/3dmodels/Adom/
     (3D models delivered via install_footprint's model arg, 0.9.107)
  4. Mesa fallback   — opengl32.dll / libgallium_wgl.dll / dxil.dll (+ *.local)
     that kicad_enable_software_opengl dropped into a KiCad bin/ (KiCad never
     ships these in bin, so presence == our deploy)
  5. Discovery files — %TEMP%/adom-kicad-bridge-*.json
  6. Screenshot dir  — C:/tmp/adom-desktop-screenshots
  7. Demo project    — Documents/adom-kicad-demo (generated by kicad_demo)

Deliberately NOT removed: the user's KiCad config beyond our two lib-table rows,
any user project, KiCad itself.
"""
import os
import shutil
import sys
import tempfile
from pathlib import Path

IS_WINDOWS = sys.platform == "win32"

_MESA_DLLS = ("opengl32.dll", "libgallium_wgl.dll", "dxil.dll")


def _kicad_user_roots() -> list[Path]:
    """Documents/KiCad/<ver> dirs (all versions, not just the active one)."""
    up = os.environ.get("USERPROFILE")
    if not up:
        return []
    root = Path(up) / "Documents" / "KiCad"
    if not root.is_dir():
        return []
    return [d for d in root.iterdir() if d.is_dir()]


def _kicad_config_roots() -> list[Path]:
    """%APPDATA%/kicad/<ver> dirs."""
    ap = os.environ.get("APPDATA")
    if not ap:
        return []
    root = Path(ap) / "kicad"
    if not root.is_dir():
        return []
    return [d for d in root.iterdir() if d.is_dir()]


def _kicad_bin_dirs(kicad_info: dict) -> list[Path]:
    """Every KiCad bin/ we can find (all installs, both scopes)."""
    dirs: list[Path] = []
    bd = kicad_info.get("bin_dir")
    if bd:
        dirs.append(Path(bd))
    for env, sub in (("ProgramFiles", "KiCad"), ("LOCALAPPDATA", "Programs/KiCad")):
        base = os.environ.get(env)
        if not base:
            continue
        root = Path(base) / sub
        if root.is_dir():
            for ver in root.iterdir():
                b = ver / "bin"
                if b.is_dir():
                    dirs.append(b)
    seen, out = set(), []
    for d in dirs:
        k = str(d).lower()
        if k not in seen:
            seen.add(k)
            out.append(d)
    return out


def handle_uninstall(kicad_info: dict, args: dict) -> dict:
    """Remove every artifact this bridge created.

    Args:
      dryRun (default false)      — preview only.
      testResidue (default false) — remove ONLY what the daily verb suite
        creates (kicad-bridge-test skill): the verbtest fixture dir, the demo
        project, the generated ADOM_VERBTEST symbol/footprint inside the Adom
        libraries, the AdomVerbTest lib-table registration, and stale discovery
        files. Leaves the plugin, the Adom libraries themselves, and every
        user artifact alone. This is how the suite cleans up after every run
        (John, 2026-08-14: "clean up after yourself").
    """
    dry = bool(args.get("dryRun"))
    test_residue = bool(args.get("testResidue"))
    removed: list[str] = []
    failed: list[dict] = []
    absent = 0

    def rm_file(p: Path, label: str):
        nonlocal absent
        try:
            if not p.exists():
                absent += 1
                return
            if not dry:
                p.unlink()
            removed.append(f"{label}: {p}")
        except Exception as e:
            failed.append({"item": f"{label}: {p}", "error": str(e)})

    def _rmtree_onerror(func, path, _exc):
        # Windows: git object files are read-only; chmod +w and retry.
        try:
            os.chmod(path, 0o700)
            func(path)
        except Exception:
            raise

    def rm_tree(p: Path, label: str):
        nonlocal absent
        try:
            if not p.exists():
                absent += 1
                return
            if not dry:
                shutil.rmtree(p, onerror=_rmtree_onerror)
            removed.append(f"{label}: {p}")
        except Exception as e:
            failed.append({"item": f"{label}: {p}", "error": str(e)})

    if test_residue:
        return _remove_test_residue(kicad_info, dry, rm_file, rm_tree,
                                    removed, failed, lambda: absent)

    # 1) plugin payload (+ pycache), 2) Adom symbol lib file, 3) Adom.pretty
    for ver_dir in _kicad_user_roots():
        site = ver_dir / "3rdparty" / "Python311" / "site-packages"
        rm_file(site / "adom_bridge.py", "plugin payload")
        rm_file(site / "usercustomize.py", "plugin loader")
        pyc = site / "__pycache__"
        if pyc.is_dir():
            for f in pyc.glob("adom_bridge*.pyc"):
                rm_file(f, "plugin pycache")
            for f in pyc.glob("usercustomize*.pyc"):
                rm_file(f, "plugin pycache")
        rm_file(ver_dir / "symbols" / "Adom.kicad_sym", "Adom symbol library")
        rm_tree(ver_dir / "footprints" / "Adom.pretty", "Adom footprint library")
        rm_tree(ver_dir / "3dmodels" / "Adom", "Adom 3D model library")

    # lib-table rows (sym + fp) in every config version
    try:
        from lib_table import LibTable
        for cfg in _kicad_config_roots():
            for table_name in ("sym-lib-table", "fp-lib-table"):
                tp = cfg / table_name
                if not tp.exists():
                    absent += 1
                    continue
                try:
                    table = LibTable.parse_file(str(tp))
                    if table.has_library("Adom"):
                        if not dry:
                            table.remove_library("Adom")
                            table.write_file(str(tp))
                        removed.append(f"'Adom' row in {tp}")
                    else:
                        absent += 1
                except Exception as e:
                    failed.append({"item": f"'Adom' row in {tp}", "error": str(e)})
    except Exception as e:
        failed.append({"item": "lib-table cleanup", "error": str(e)})

    # 4) Mesa software-OpenGL deploy
    for b in _kicad_bin_dirs(kicad_info):
        for dll in _MESA_DLLS:
            rm_file(b / dll, "mesa fallback")
            rm_file(b / (dll + ".local"), "mesa fallback marker")
        for loc in b.glob("*.exe.local"):
            rm_file(loc, "mesa dotlocal")

    # 5) discovery files
    tmp = Path(tempfile.gettempdir())
    for f in tmp.glob("adom-kicad-bridge-*.json"):
        rm_file(f, "plugin discovery file")

    # 6) screenshot cache, 7) demo project, 8) part exports/staging (0.9.109)
    rm_tree(Path("C:/tmp/adom-desktop-screenshots"), "screenshot cache")
    up = os.environ.get("USERPROFILE")
    if up:
        rm_tree(Path(up) / "Documents" / "adom-kicad-demo", "demo project")
        rm_tree(Path(up) / "Documents" / "adom-part-exports", "one-off part exports")
        rm_tree(Path(up) / "Documents" / "adom-part-staging", "part install staging")

    ok = len(failed) == 0
    return {
        "success": ok,
        # ab 2.0.35+: bridge_uninstall cascades into this verb with
        # {cascade:true, name:"kicad"} while our files still exist; echo it so
        # the cascade report on ab's side shows this ran as part of a cascade.
        "cascade": bool(args.get("cascade")),
        "dryRun": dry,
        "removed": removed,
        "failed": failed,
        "alreadyClean": len(removed) == 0 and ok,
        "summary": f"{'would remove' if dry else 'removed'} {len(removed)} item(s), "
                   f"{len(failed)} failed, {absent} already absent",
        "_hint": ("Bridge-created artifacts only — KiCad itself, user projects, and other bridges are "
                  "untouched. Idempotent: safe to re-run. A running KiCad may hold the plugin's "
                  ".pyc open; re-run after KiCad exits if anything failed. Bridge removes its own "
                  "bridges-cache during the cascade."),
    }


def _remove_test_residue(kicad_info, dry, rm_file, rm_tree, removed, failed, absent_fn):
    """The suite-residue subset (see handle_uninstall docstring)."""
    up = os.environ.get("USERPROFILE")
    if up:
        rm_tree(Path(up) / "Documents" / "adom-kicad-verbtest", "verbtest fixture dir")
        rm_tree(Path(up) / "Documents" / "adom-kicad-demo", "demo project")
    # ADOM_VERBTEST entries inside the Adom libraries (surgical, not the libs)
    for ver_dir in _kicad_user_roots():
        rm_file(ver_dir / "footprints" / "Adom.pretty" / "ADOM_VERBTEST.kicad_mod",
                "test footprint")
        sym = ver_dir / "symbols" / "Adom.kicad_sym"
        if sym.exists():
            try:
                import adom_library
                text = sym.read_text(encoding="utf-8")
                blocks = adom_library._extract_top_level_symbols(text)
                hit = [b for n, b in blocks if n == "ADOM_VERBTEST"]
                if hit:
                    if not dry:
                        for b in hit:
                            text = text.replace(b, "")
                        sym.write_text(text, encoding="utf-8")
                    removed.append(f"ADOM_VERBTEST symbol in {sym}")
            except Exception as e:
                failed.append({"item": f"ADOM_VERBTEST in {sym}", "error": str(e)})
    # AdomVerbTest lib-table registration (points at the deleted fixture dir)
    try:
        from lib_table import LibTable
        for cfg in _kicad_config_roots():
            for table_name in ("sym-lib-table", "fp-lib-table"):
                tp = cfg / table_name
                if not tp.exists():
                    continue
                try:
                    table = LibTable.parse_file(str(tp))
                    if table.has_library("AdomVerbTest"):
                        if not dry:
                            table.remove_library("AdomVerbTest")
                            table.write_file(str(tp))
                        removed.append(f"'AdomVerbTest' row in {tp}")
                except Exception as e:
                    failed.append({"item": f"'AdomVerbTest' row in {tp}", "error": str(e)})
    except Exception as e:
        failed.append({"item": "lib-table cleanup", "error": str(e)})
    # stale discovery files (dead pids)
    tmp = Path(tempfile.gettempdir())
    for f in tmp.glob("adom-kicad-bridge-*.json"):
        try:
            pid = int(f.stem.rsplit("-", 1)[-1])
        except ValueError:
            rm_file(f, "legacy discovery file")
            continue
        try:
            import ctypes
            h = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
            if h:
                ctypes.windll.kernel32.CloseHandle(h)
            else:
                rm_file(f, "stale discovery file")
        except Exception:
            pass
    ok = len(failed) == 0
    return {
        "success": ok, "testResidue": True, "dryRun": dry,
        "removed": removed, "failed": failed,
        "alreadyClean": len(removed) == 0 and ok,
        "summary": f"{'would remove' if dry else 'removed'} {len(removed)} test-residue item(s), "
                   f"{len(failed)} failed",
        "_hint": ("Suite residue only — plugin, Adom libraries, KiCad and user files untouched. "
                  "Run after every verb-suite pass; idempotent."),
    }