projet crepp_git/crepp-projects/station-meteo/hardware/transmetteur/base/v1.1 · branche main
kicad_cli_utils.py Voir sur GitLab
# -*- coding: utf-8 -*-
"""
kicad_cli_utils.py — Résolution et exécution de kicad-cli, partagées entre
le plugin GUI (pcb_assembly.py) et le script d'automatisation CI/CD
(generate_ci.py). Aucune dépendance à wx ni à kipy : juste subprocess/shlex.
"""

from __future__ import annotations

import glob
import os
import platform
import shlex
import shutil
import subprocess

try:
    # Disponible uniquement dans le contexte du plugin GUI KiCad.
    from Settings import render as render_cfg
except ImportError:
    # Contexte CI/script autonome (export_board.py, gitCommit.sh...) : pas de
    # module Settings du plugin. Repli sur des variables d'environnement
    # (surchargeables) avec des valeurs par défaut raisonnables — Flatpak
    # étant l'installation KiCad la plus courante sur les runners CI Linux
    # ne fournissant pas de paquet natif.
    class _EnvRenderCfg:
        KICAD_CLI_LINUX = os.environ.get("KICAD_CLI_LINUX", "flatpak run --command=kicad-cli org.kicad.KiCad")
        KICAD_CLI_WINDOWS = os.environ.get("KICAD_CLI_WINDOWS", "kicad-cli")
        KICAD_CLI_MACOS = os.environ.get("KICAD_CLI_MACOS", "kicad-cli")

    render_cfg = _EnvRenderCfg()


def _find_windows_kicad_cli() -> str | None:
    """Cherche kicad-cli.exe dans les emplacements d'installation Windows
    courants, pour le cas fréquent où l'utilisateur n'a pas ajouté KiCad à
    son PATH :
    - installation "pour tous les utilisateurs" (admin) : Program Files
    - installation "pour cet utilisateur seulement" (sans droits admin) :
      %LOCALAPPDATA%\\Programs\\KiCad — confirmé comme emplacement réel chez
      un utilisateur (C:\\Users\\...\\AppData\\Local\\Programs\\KiCad\\10.0\\bin).
    """
    patterns = [
        r"C:\Program Files\KiCad\*\bin\kicad-cli.exe",
        r"C:\Program Files (x86)\KiCad\*\bin\kicad-cli.exe",
    ]
    local_appdata = os.environ.get("LOCALAPPDATA")
    if local_appdata:
        patterns.append(os.path.join(local_appdata, "Programs", "KiCad", "*", "bin", "kicad-cli.exe"))

    candidates = []
    for pattern in patterns:
        candidates.extend(glob.glob(pattern))
    if not candidates:
        return None

    # Si plusieurs versions sont installées, prendre la plus récente.
    # IMPORTANT : un tri alphabétique classe "9.0" APRÈS "10.0" (le
    # caractère '9' > '1'), ce qui sélectionnait à tort la version 9 au
    # lieu de la 10 — bug réel observé. On extrait le numéro de version du
    # chemin (dossier juste avant "bin") et on trie numériquement.
    def _version_key(path: str) -> tuple:
        version_str = os.path.basename(os.path.dirname(os.path.dirname(path)))
        parts = []
        for chunk in version_str.split('.'):
            try:
                parts.append(int(chunk))
            except ValueError:
                parts.append(0)
        return tuple(parts)

    candidates.sort(key=_version_key, reverse=True)
    return candidates[0]


def _find_macos_kicad_cli() -> str | None:
    """Cherche kicad-cli dans l'emplacement d'installation macOS standard."""
    path = "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"
    return path if os.path.isfile(path) else None


def _find_flatpak_binary() -> str | None:
    """Cherche l'exécutable flatpak, en repli sur les emplacements binaires
    standards si shutil.which('flatpak') échoue.

    Cas réel observé : flatpak est installé et fonctionne très bien dans un
    terminal utilisateur, mais le plugin (lancé par KiCad, lui-même lancé
    par l'environnement de bureau) hérite d'un PATH plus restreint qui
    n'inclut pas le dossier où flatpak est installé — un problème
    d'environnement de processus, pas d'installation. shutil.which() ne
    peut pas trouver un binaire hors du PATH du processus courant, d'où ce
    repli sur les emplacements universels des distributions Linux."""
    found = shutil.which("flatpak")
    if found:
        return found
    for path in ("/usr/bin/flatpak", "/usr/local/bin/flatpak", "/bin/flatpak"):
        if os.path.isfile(path):
            return path
    return None


def _find_linux_kicad_cli() -> str | None:
    """Cherche kicad-cli natif dans les emplacements d'installation Linux
    courants, pour le même problème de PATH restreint que celui documenté
    ci-dessus pour Flatpak : le plugin est lancé par KiCad, lui-même lancé
    par l'environnement de bureau, qui peut hériter d'un PATH plus restreint
    qu'un terminal utilisateur — shutil.which('kicad-cli') peut donc échouer
    alors que KiCad est bel et bien installé nativement (paquet système,
    AppImage extrait, Snap...).

    Sans ce repli, resolve_kicad_cli() retombait directement sur la
    commande Flatpak configurée par défaut dès que which('kicad-cli')
    échouait — cassant tout sur une machine à l'installation native où
    Flatpak/org.kicad.KiCad n'est pas installé, pour un simple souci de
    PATH restreint plutôt qu'une réelle absence de KiCad."""
    for path in ("/usr/bin/kicad-cli", "/usr/local/bin/kicad-cli", "/snap/bin/kicad-cli"):
        if os.path.isfile(path):
            return path
    for path in sorted(glob.glob("/opt/kicad*/bin/kicad-cli"), reverse=True):
        if os.path.isfile(path):
            return path
    return None


def resolve_kicad_cli() -> list[str]:
    """Retourne la commande kicad-cli sous forme de liste de tokens.

    IMPORTANT : sous Linux, KiCad peut être installé de plusieurs façons —
    paquet natif de la distribution (kicad-cli alors directement dans le
    PATH), Flatpak (nécessite "flatpak run --command=kicad-cli
    org.kicad.KiCad", PLUSIEURS mots), AppImage, etc. Settings.render.KICAD_CLI_LINUX
    suppose Flatpak par défaut, ce qui casse tout pour une install native —
    cas réel observé ("flatpak introuvable dans le PATH" alors que KiCad
    fonctionne très bien). On teste donc d'abord si 'kicad-cli' tout court
    répond déjà (install native), et on ne retombe sur la commande
    configurée (Flatpak par défaut) que si ce n'est pas le cas.

    Pour le cas Flatpak, un DEUXIÈME souci d'environnement a aussi été
    observé : flatpak est bien installé et fonctionne dans un terminal,
    mais le plugin (lancé par KiCad, elle-même lancée par l'environnement
    de bureau) hérite d'un PATH plus restreint qui ne l'inclut pas — voir
    _find_flatpak_binary(), qui cherche aussi dans les emplacements
    binaires standards des distributions, pas seulement via le PATH hérité.

    Ce même problème de PATH restreint touche aussi le cas natif Linux :
    voir _find_linux_kicad_cli(), utilisé en repli AVANT de retomber sur la
    commande configurée (Flatpak par défaut), pour ne pas confondre un
    simple souci de PATH sur une install native avec une absence réelle de
    Flatpak.

    subprocess.run() est appelé sans shell=True : il faut donc une vraie
    liste d'arguments, pas une seule chaîne contenant des espaces.

    Si la commande configurée n'est pas trouvable dans le PATH sur
    Windows/macOS, on essaie aussi les emplacements d'installation par
    défaut avant d'abandonner — cas fréquent : KiCad est installé mais son
    dossier `bin` n'a pas été ajouté au PATH système.
    """
    system = platform.system()

    if system == "Linux" and shutil.which("kicad-cli") is not None:
        return ["kicad-cli"]

    if system == "Linux":
        # which() a pu échouer uniquement à cause d'un PATH restreint hérité
        # du processus GUI (même problème que documenté pour Flatpak plus
        # bas) : on tente d'abord les emplacements natifs standards, AVANT
        # de retomber sur la commande configurée (Flatpak par défaut) — sans
        # quoi un simple souci de PATH sur une install native se traduirait
        # à tort par un échec "Flatpak introuvable".
        native_path = _find_linux_kicad_cli()
        if native_path:
            return [native_path]

    if system == "Linux":
        raw = render_cfg.KICAD_CLI_LINUX
    elif system == "Windows":
        raw = render_cfg.KICAD_CLI_WINDOWS
    else:
        raw = render_cfg.KICAD_CLI_MACOS

    tokens = shlex.split(raw)

    if system == "Linux" and tokens and tokens[0] == "flatpak" and shutil.which("flatpak") is None:
        flatpak_path = _find_flatpak_binary()
        if flatpak_path:
            tokens = [flatpak_path] + tokens[1:]
        return tokens

    if tokens and shutil.which(tokens[0]) is None:
        fallback = None
        if system == "Windows":
            fallback = _find_windows_kicad_cli()
        elif system == "Darwin":
            fallback = _find_macos_kicad_cli()
        if fallback:
            tokens = [fallback] + tokens[1:]
    return tokens


def check_kicad_cli() -> tuple[bool, str]:
    """Vérifie que kicad-cli est bien accessible (exécutable trouvé + répond).

    Returns:
        (ok, message) — message détaillé destiné à être affiché/loggé.
    """
    tokens = resolve_kicad_cli()
    if not tokens:
        return False, "Chemin kicad-cli vide dans Settings.render."

    executable = tokens[0]
    # resolve_kicad_cli() a déjà tenté un repli sur les emplacements
    # d'installation par défaut : si l'exécutable n'est toujours pas
    # trouvable ici, shutil.which() confirme l'échec pour de bon.
    resolved = shutil.which(executable) or (os.path.isfile(executable) and executable)
    if not resolved:
        system = platform.system()
        if system == "Windows":
            hint = (
                "Sous Windows, vérifiez que KiCad est installé et que son dossier "
                r"'bin' (ex. C:\Program Files\KiCad\9.0\bin) est ajouté à la "
                "variable d'environnement PATH, puis redémarrez KiCad."
            )
        elif system == "Darwin":
            hint = (
                "Sous macOS, vérifiez que KiCad est installé dans /Applications/KiCad "
                "et que son dossier Contents/MacOS est accessible."
            )
        else:
            hint = (
                "Sous Linux, ni 'kicad-cli' natif ni Flatpak n'ont été trouvés : "
                "si KiCad est installé nativement (paquet de la distribution, "
                "AppImage...), vérifiez que 'kicad-cli' est bien dans le PATH "
                "(essayez 'which kicad-cli' dans un terminal). Si KiCad est "
                "installé via Flatpak, vérifiez que 'flatpak' est installé et "
                "que 'org.kicad.KiCad' est bien enregistré "
                "(flatpak list | grep kicad)."
            )
        return False, (
            f"Exécutable introuvable dans le PATH : '{executable}'.\n"
            f"Commande configurée : {' '.join(tokens)}\n"
            f"{hint}"
        )

    try:
        result = subprocess.run(
            tokens + ["version"],
            capture_output=True,
            text=True,
            timeout=10,
        )
    except FileNotFoundError as e:
        return False, f"Commande introuvable : {' '.join(tokens)} ({e})"
    except subprocess.TimeoutExpired:
        return False, f"kicad-cli ne répond pas après 10s : {' '.join(tokens)}"
    except Exception as e:
        return False, f"Erreur lors du test de kicad-cli : {e}"

    if result.returncode != 0:
        stderr = (result.stderr or "").strip()[:300]
        return False, (
            f"kicad-cli a répondu avec une erreur (code {result.returncode}).\n"
            f"Commande : {' '.join(tokens)}\n{stderr}"
        )

    version = (result.stdout or result.stderr or "").strip() or "version inconnue"
    return True, f"kicad-cli OK — {version}\nCommande utilisée : {' '.join(tokens)}"


def run_cli(cmd: list, description: str = "", reporter=None, timeout: int = 60) -> bool:
    """Exécute une commande kicad-cli, capture stdout/stderr, reporte les
    erreurs via `reporter` (tout objet avec des méthodes info/warning/error
    — voir reporter.py). Si `reporter` est None, imprime sur stdout/stderr.
    """
    def _report_error(msg):
        if reporter is not None:
            reporter.error(msg)
        else:
            print(f"[ERREUR] {msg}")

    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    except FileNotFoundError:
        _report_error(
            f"kicad-cli introuvable ({description}). Commande : {' '.join(cmd)!r}"
        )
        return False
    except subprocess.TimeoutExpired:
        _report_error(f"kicad-cli ne répond pas ({description}, >{timeout}s).")
        return False

    if result.returncode != 0:
        stderr = (result.stderr or result.stdout or "").strip()[:400]
        _report_error(
            f"Erreur rendu ({description}) — code {result.returncode}.\n"
            f"Commande : {' '.join(cmd)}\n{stderr}"
        )
        return False
    return True


if __name__ == "__main__":
    # Usage : python3 kicad_cli_utils.py
    # Affiche le résultat de la résolution/vérification de kicad-cli et sort
    # avec le code 0 (trouvé et fonctionnel) ou 1 (introuvable/en erreur) —
    # pensé pour être appelé depuis un script bash (voir gitCommit.sh) sans
    # dupliquer la logique de résolution (Flatpak, PATH restreint, chemins
    # d'installation Windows/macOS...) côté shell.
    import sys as _sys
    _ok, _msg = check_kicad_cli()
    print(_msg)
    _sys.exit(0 if _ok else 1)