master
John Lauer Update 5 file(s) 5aec99d 2mo ago
"""plugin_diagnose.py — kicad_plugin_diagnose: why is the reverse bridge not loading?

Born out of wiki issue #34 (Colby Knox, 2026-08-19). The plugin binds on both of
this maintainer's boxes and not on his, and answering that took several rounds of
"please send me this file, now that one". Every artifact needed to tell the four
possible causes apart is readable from the box itself, so this verb collects them
in one call:

  1. Was the loader DEPLOYED?            -> the files, with sizes and ages
  2. Did KiCad IMPORT it?                -> a __pycache__ entry next to it is proof
  3. Did the loader RUN and what did it say? -> its own log + the process breadcrumbs
  4. Could the payload import at all?     -> KiCad's embedded stdlib, which has been
                                             found incomplete on a real machine
                                             (a missing Lib/urllib made every import
                                             of the payload fail; no bridge code can
                                             work around that)

Read-only. No windows, no side effects.
"""
import json
import os
import time
from pathlib import Path


def _stat(p: Path) -> dict:
    try:
        st = p.stat()
        return {"exists": True, "bytes": st.st_size,
                "ageMin": int((time.time() - st.st_mtime) / 60)}
    except Exception:
        return {"exists": False}


def _dir_listing(d: Path, pattern: str = "*") -> list:
    out = []
    try:
        for p in sorted(d.glob(pattern)):
            e = _stat(p)
            e["name"] = p.name
            out.append(e)
    except Exception:
        pass
    return out


def _tail(p: Path, lines: int = 25) -> list:
    try:
        return p.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
    except Exception:
        return []


def handle_plugin_diagnose(kicad_info: dict, args: dict) -> dict:
    """Collect every artifact that explains whether the reverse-bridge plugin
    loaded, and why not. Read-only; safe to run any time."""
    appdata = os.environ.get("APPDATA") or ""
    userprofile = os.environ.get("USERPROFILE") or str(Path.home())
    temp = Path(os.environ.get("TEMP") or "/tmp")
    version = str(kicad_info.get("version") or "10.0")

    report = {"success": True, "kicadVersion": version}

    # 1 + 2: the plugin-directory loader, and whether KiCad imported it
    pdirs = []
    base = Path(appdata) / "kicad" if appdata else None
    if base:
        for vdir in sorted([d for d in base.glob("*") if d.is_dir()]):
            pdir = vdir / "scripting" / "plugins"
            if not pdir.exists():
                continue
            loader = pdir / "adom_bridge_plugin.py"
            pyc = _dir_listing(pdir / "__pycache__", "adom_bridge_plugin*.pyc")
            pdirs.append({
                "dir": str(pdir),
                "loader": {**_stat(loader), "name": "adom_bridge_plugin.py"},
                "importedByKiCad": bool(pyc),      # a .pyc is proof of import
                "pycache": pyc,
                "otherFiles": [f["name"] for f in _dir_listing(pdir, "*.py")],
            })
    report["pluginDirs"] = pdirs

    # the user-site payload (the other deploy target)
    site = Path(userprofile) / "Documents" / "KiCad" / version / "3rdparty" / "Python311" / "site-packages"
    report["userSite"] = {"path": str(site), "files": _dir_listing(site, "adom*")}
    std_site = Path(appdata) / "Python" / "Python311" / "site-packages" if appdata else None
    if std_site:
        report["stdUserSite"] = {"path": str(std_site), "files": _dir_listing(std_site, "adom*")}

    # 3: what the loader itself said, and which exes ever ran the bootstrap
    report["loaderLog"] = _tail(temp / "adom-kicad-plugin-loader.log")
    report["breadcrumbs"] = [
        {"name": p.name, **_stat(p)} for p in sorted(temp.glob("adom-kicad-process-*.json"))
    ]
    report["loaderErrors"] = {p.name: _tail(p, 12)
                              for p in sorted(temp.glob("adom-kicad-loader-*.error.log"))}

    # 4: is KiCad's embedded stdlib complete? (a real machine was missing urllib,
    # which made every import of the payload fail with ModuleNotFoundError)
    stdlib = {}
    bin_dir = Path(kicad_info.get("kicad_exe") or "").parent if kicad_info.get("kicad_exe") else None
    if bin_dir and bin_dir.exists():
        lib = bin_dir / "Lib"
        stdlib["libDir"] = str(lib)
        for mod in ("urllib", "email", "http", "json", "encodings", "socket.py", "threading.py"):
            stdlib[mod] = (lib / mod).exists()
        stdlib["complete"] = all(v for k, v in stdlib.items()
                                 if isinstance(v, bool))
    report["embeddedStdlib"] = stdlib

    # live instances, for completeness
    try:
        from handlers.bridge_client import handle_bridge_status
        st = handle_bridge_status(kicad_info, {})
        report["live"] = st.get("summary") or {}
        report["instances"] = [{k: p.get(k) for k in ("alive", "exeName", "pid", "port")}
                               for p in (st.get("plugins") or [])]
    except Exception as e:
        report["live"] = {"error": str(e)[:120]}

    # 0.9.194: the loader log is written by ANY process that runs the bootstrap,
    # including the bridge's own short-lived `python.exe -c ...` probes. Judging
    # "did the plugin run" from those lines is how this verb told a live box
    # "IMPORTED BUT DID NOT START" while no KiCad was running at all - a
    # confident answer to a question nobody could answer yet. Split the log by
    # process and only count GUI lines as evidence about KiCad.
    gui_names = ("kicad", "eeschema", "pcbnew", "gerbview", "pl_editor", "bitmap2component")
    gui_log, probe_log = [], []
    for line in report["loaderLog"]:
        low = line.lower()
        (gui_log if any(f"in {n}.exe" in low for n in gui_names) else probe_log).append(line)
    report["loaderLogGui"] = gui_log
    report["loaderLogProbes"] = probe_log

    try:
        from handlers.kicad_ui import _find_all_kicad_windows
        kicad_running = bool(_find_all_kicad_windows())
    except Exception:
        kicad_running = bool(report.get("instances"))
    report["kicadRunning"] = kicad_running

    # the verdict, so a human does not have to correlate five sections by hand
    deployed = any(d["loader"].get("exists") for d in pdirs)
    imported = any(d["importedByKiCad"] for d in pdirs)
    ran = bool(gui_log)          # GUI evidence only, never a probe process
    alive = (report.get("live") or {}).get("alive") or 0
    if alive:
        verdict = "WORKING: the plugin is bound and answering."
    elif not kicad_running:
        verdict = ("KICAD NOT RUNNING: there is nothing for the plugin to load into "
                   "yet, so this says nothing about whether it works. Launch KiCad "
                   "and open a board editor, then re-run. "
                   + ("The loader IS deployed." if deployed else
                      "The loader is NOT deployed - run kicad_install_plugin."))
    elif not deployed:
        verdict = ("NOT DEPLOYED: adom_bridge_plugin.py is missing from KiCad's "
                   "scripting/plugins. Run kicad_install_plugin.")
    elif not imported and not ran:
        verdict = ("DEPLOYED BUT NEVER IMPORTED: no __pycache__ entry and no loader "
                   "log, so KiCad has not scanned its plugin directory in this "
                   "install. That scan happens when a BOARD EDITOR opens - if one "
                   "has been opened and this still says never imported, this KiCad "
                   "build does not scan that directory and the hook has to change.")
    elif ran and not alive:
        verdict = ("IMPORTED BUT DID NOT START: read loaderLogGui below - it names the "
                   "exact failure. 'No module named X' means KiCad's embedded stdlib "
                   "is incomplete (see embeddedStdlib); a repair install fixes it.")
    else:
        verdict = ("IMPORTED, no log yet: the loader arms a background thread and "
                   "retries the payload import for ~60s. Re-run shortly.")
    report["verdict"] = verdict
    report["_hint"] = (
        "One-call answer to 'why is the reverse bridge not loading'. importedByKiCad "
        "is the key field: a __pycache__ entry beside the loader is proof KiCad "
        "imported it. If deployed but never imported, the plugin-directory scan is "
        "not happening in this build. If imported but not alive, loaderLog names the "
        "cause. Wiki issue #34."
    )
    return report