Ctrl K
ComunidadGuías…

build.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Construit le bundle autonome dans un environnement isolé jetable.

Pourquoi un environnement dédié à la construction, distinct de celui
d'exécution : PyInstaller embarque ce qu'il trouve. Construire depuis
l'installation Python courante ferait entrer dans le bundle tout ce qui traîne
dans site-packages, sans rapport avec l'outil.

Le profil normal suit les dépendances courantes. Le profil ``--win7`` est une
cible legacy séparée et reproductible : CPython 3.8.10 x64, dépendances figées
et roue blinkpy 0.25.9 dont seules les métadonnées sont rétroportées.

    python build.py                   construit le bundle normal
    python build.py --propre          le reconstruit depuis zéro
    python build.py --win7 --propre   construit le candidat Windows 7
"""

from __future__ import annotations  # Python 3.8 (build Windows 7) : les annotations "X | None" ne s'évaluent qu'à l'écriture des chaînes, jamais à l'exécution.

import argparse
import hashlib
import inspect
import os
import shutil
import struct
import subprocess
import sys
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
SPEC = BASE_DIR / "blink2video.spec"

# Profil ordinaire : verrouillé et à empreintes (requirements-build.in -> .txt
# via uv pip compile, voir l'en-tête de requirements.in), même esprit que
# WIN7_REQUIREMENTS ci-dessous. blink_engine.py
# rattrape une API privée de blinkpy (monkey-patch sur BlinkLiveStream) ;
# une version flottante pourrait la casser en silence à la prochaine release.
REQUIREMENTS = BASE_DIR / "requirements-build.txt"

# Profil Windows 7 : jamais mélangé au venv ni aux sorties officielles.
WIN7_PYTHON = (3, 8, 10)
WIN7_VENV = BASE_DIR / "build_venv_win7"
WIN7_WORK = BASE_DIR / "build-win7"
WIN7_DIST = BASE_DIR / "dist-win7"
WIN7_REQUIREMENTS = BASE_DIR / "requirements-win7.txt"


# Compilation complète de secours, quand celle fournie par imageio-ffmpeg ne
# sait pas incruster de texte. C'est le cas sous Linux, où elle est produite
# sans libfreetype. Variante « gpl » : c'est celle qui embarque libfreetype.
#
# Version et empreinte FIGÉES (issue #49) : l'ancienne adresse « latest »
# changeait à chaque nuit, donc deux releases Linux construites à un jour
# d'écart pouvaient embarquer deux FFmpeg différents, sans qu'une ligne de code
# ait bougé, et un exécutable téléchargé à la construction était livré sans
# vérification. On vise la branche stable 8.1 (pas la branche de développement)
# d'une construction datée, dont BtbN publie la somme dans checksums.sha256 ;
# l'empreinte ci-dessous a été recalculée à la main sur l'archive téléchargée.
# Pour changer de version : prendre une autre release « autobuild-AAAA-MM-JJ-… »
# de https://github.com/BtbN/FFmpeg-Builds/releases, recopier le nom de
# l'archive 8.1 et sa ligne de checksums.sha256, puis rejouer la validation des
# clips USB (valid_mp4_complet) sur de vrais clips avant de publier.
FFMPEG_SECOURS_TAG = "autobuild-2026-09-30-13-08"
FFMPEG_SECOURS_ARCHIVE = "ffmpeg-n8.1.3-9-g29e619e767-linux64-gpl-8.1.tar.xz"
FFMPEG_SECOURS_SHA256 = "97ce978979194b5cf7e06a5e68020dbdaa7a4f3294c5452b6a1bc347100dbb79"
FFMPEG_SECOURS = (
    "https://github.com/BtbN/FFmpeg-Builds/releases/download/"
    f"{FFMPEG_SECOURS_TAG}/{FFMPEG_SECOURS_ARCHIVE}"
)


def empreinte_sha256(chemin: Path) -> str:
    """SHA-256 d'un fichier, lu par blocs : l'archive pèse environ 150 Mo."""
    somme = hashlib.sha256()
    with open(chemin, "rb") as fichier:
        for bloc in iter(lambda: fichier.read(1 << 20), b""):
            somme.update(bloc)
    return somme.hexdigest()


def verifier_empreinte(chemin: Path, attendue: str) -> bool:
    """Vrai si le fichier a bien l'empreinte attendue (casse indifférente)."""
    return empreinte_sha256(chemin) == attendue.strip().lower()


def _chemins(win7: bool) -> tuple:
    if win7:
        return WIN7_VENV, WIN7_WORK, WIN7_DIST
    return BASE_DIR / "build_venv", BASE_DIR / "build", BASE_DIR / "dist"


def _python(venv: Path) -> Path:
    return venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")


def verifier_interpreteur_win7() -> None:
    """Refuse un runtime dont les DLL ne sont pas celles garanties pour Win7."""
    erreurs = []
    if sys.platform != "win32":
        erreurs.append("la construction doit tourner sous Windows")
    if sys.version_info[:3] != WIN7_PYTHON:
        erreurs.append(
            "CPython 3.8.10 exact est requis "
            f"(reçu {sys.version_info.major}.{sys.version_info.minor}."
            f"{sys.version_info.micro})"
        )
    if struct.calcsize("P") != 8:
        erreurs.append("un interpréteur x86-64 est requis")
    if sys.implementation.name != "cpython":
        erreurs.append("l'interpréteur doit être CPython")
    if erreurs:
        raise SystemExit("Build Windows 7 refusé : " + "; ".join(erreurs) + ".")


def verifier_python_win7(python: Path) -> None:
    """Refuse aussi un ancien venv Win7 créé avec un autre interpréteur."""
    programme = (
        "import struct,sys; "
        "print('%d.%d.%d|%d|%s' % "
        "(sys.version_info.major, sys.version_info.minor, sys.version_info.micro, "
        "struct.calcsize('P') * 8, sys.implementation.name))"
    )
    attendu = ".".join(str(partie) for partie in WIN7_PYTHON) + "|64|cpython"
    try:
        resultat = subprocess.run(
            [str(python), "-c", programme],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=30,
            check=False,
        )
    except OSError as erreur:
        raise SystemExit(
            f"Build Windows 7 refusé : environnement illisible ({erreur}). "
            "Relancer avec --propre."
        ) from erreur
    obtenu = (resultat.stdout or "").strip()
    if resultat.returncode != 0 or obtenu != attendu:
        detail = obtenu or (resultat.stderr or "").strip() or "identité inconnue"
        raise SystemExit(
            f"Build Windows 7 refusé : l'environnement utilise {detail}, "
            f"attendu {attendu}. Relancer avec --propre."
        )


def ffmpeg_utilisable(python: Path, travail: Path) -> tuple[str, str | None]:
    """Choisit le ffmpeg à embarquer, en exigeant qu'il sache écrire du texte.

    Renvoie aussi un ffprobe si l'occasion se présente (secours Linux
    seulement, voir plus bas) : sans lui, merge_daily.valid_mp4_complet()
    retombe sur une copie de flux ffmpeg avec -xerror pour valider un
    téléchargement, bien plus stricte qu'un simple comptage de paquets
    ffprobe et prompte à refuser un fichier pourtant exploitable (signalé
    sur l'issue GitHub #10, uniquement sous Linux)."""

    def sait_ecrire(binaire: str) -> bool:
        try:
            sortie = subprocess.run(
                [binaire, "-hide_banner", "-filters"],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                errors="replace",
                timeout=60,
                check=False,
            ).stdout
        except Exception:
            return False
        return "drawtext" in (sortie or "")

    try:
        resultat = subprocess.run(
            [str(python), "-c",
             "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())"],
            stdout=subprocess.PIPE,
            text=True,
            check=True,
        ).stdout.strip()
    except Exception:
        resultat = ""

    if resultat and sait_ecrire(resultat):
        # imageio_ffmpeg ne fournit jamais de ffprobe, sur aucune plateforme.
        return resultat, None

    if sys.platform != "linux":
        raise SystemExit(
            "Aucun ffmpeg capable d'incruster du texte n'a été trouvé, et aucune "
            "solution de secours n'est prévue pour cette plateforme."
        )

    print("\n=== ffmpeg fourni sans drawtext, récupération d'une compilation complète")
    import tarfile
    import urllib.request

    archive = travail / "ffmpeg-linux.tar.xz"
    archive.parent.mkdir(parents=True, exist_ok=True)
    # Une archive déjà présente (cache de travail) est revérifiée : une copie
    # d'une autre version, ou tronquée, ne doit jamais être réutilisée.
    if archive.exists() and not verifier_empreinte(archive, FFMPEG_SECOURS_SHA256):
        archive.unlink()
    if not archive.exists():
        urllib.request.urlretrieve(FFMPEG_SECOURS, archive)
        if not verifier_empreinte(archive, FFMPEG_SECOURS_SHA256):
            obtenue = empreinte_sha256(archive)
            archive.unlink()
            raise SystemExit(
                "L'archive FFmpeg téléchargée n'a pas l'empreinte attendue "
                f"(attendue {FFMPEG_SECOURS_SHA256}, obtenue {obtenue}) : "
                f"{FFMPEG_SECOURS}. Construction arrêtée plutôt que d'embarquer "
                "un exécutable non vérifié."
            )
    with tarfile.open(archive) as fichier:
        membres = fichier.getmembers()
        membre = next(m for m in membres if m.name.endswith("/bin/ffmpeg"))
        membre.name = "ffmpeg"
        fichier.extract(membre, archive.parent)
        # Le même tar contient déjà ffprobe (BtbN livre les deux ensemble) :
        # au passage, sans repasser par le réseau.
        membre_probe = next(
            (m for m in membres if m.name.endswith("/bin/ffprobe")), None)
        if membre_probe is not None:
            membre_probe.name = "ffprobe"
            fichier.extract(membre_probe, archive.parent)
    binaire = archive.parent / "ffmpeg"
    binaire.chmod(0o755)
    if not sait_ecrire(str(binaire)):
        raise SystemExit("La compilation de secours ne sait pas non plus écrire du texte.")
    sonde = archive.parent / "ffprobe"
    if sonde.is_file():
        sonde.chmod(0o755)
    return str(binaire), (str(sonde) if sonde.is_file() else None)


def executer(commande: list, titre: str) -> None:
    print(f"\n=== {titre}")
    resultat = subprocess.run(commande, cwd=str(BASE_DIR), check=False)
    if resultat.returncode != 0:
        raise SystemExit(f"Échec : {titre}")


def installer_win7(python: Path, travail: Path) -> None:
    """Installe le verrou Python 3.8 et la roue blinkpy reconditionnée."""
    from build_blinkpy_win7 import WIN7_WHEEL

    executer(
        [str(python), "-m", "pip", "install", "--quiet", "--upgrade",
         "pip==25.0.1"],
        "installation de pip compatible Python 3.8",
    )
    executer(
        [str(python), "-m", "pip", "install", "--quiet", "--requirement",
         str(WIN7_REQUIREMENTS)],
        "installation du verrou Windows 7",
    )
    roues = travail / "wheels"
    executer(
        [str(python), str(BASE_DIR / "build_blinkpy_win7.py"), str(roues)],
        "rétroportage des métadonnées blinkpy 0.25.9",
    )
    executer(
        [str(python), "-m", "pip", "install", "--quiet", "--force-reinstall",
         "--no-deps", str(roues / WIN7_WHEEL)],
        "installation de blinkpy pour Windows 7",
    )
    executer([str(python), "-m", "pip", "check"],
             "cohérence des dépendances Windows 7")


def verifier_blinkpy_win7(python: Path) -> None:
    """Prouve que pip n'a ni rétrogradé blinkpy ni perdu son API moderne."""
    from build_blinkpy_win7 import WIN7_VERSION

    programme = """
import compileall
import inspect
from importlib.metadata import distribution, version
from blinkpy.auth import Auth, BlinkTwoFARequiredError
from blinkpy.blinkpy import Blink
from blinkpy.camera import BlinkCamera
from blinkpy.livestream import BlinkLiveStream

attendue = %r
obtenue = version("blinkpy")
assert obtenue == attendue, (obtenue, attendue)
assert inspect.iscoroutinefunction(Blink.start)
for classe, noms in (
    (Blink, ("send_2fa_code", "get_videos_metadata")),
    (BlinkCamera, ("init_livestream",)),
    (BlinkLiveStream, ("start", "feed", "recv", "stop")),
):
    for nom in noms:
        assert hasattr(classe, nom), "%%s.%%s absent" %% (classe.__name__, nom)
assert "callback" in inspect.signature(Auth.__init__).parameters
# Les médias cloud passent par Auth.query avec leur propre délai (#139).
assert "timeout" in inspect.signature(Auth.query).parameters
racine = distribution("blinkpy").locate_file("blinkpy")
assert compileall.compile_dir(str(racine), quiet=1)
print("blinkpy %%s : API moderne compilée sous Python 3.8" %% obtenue)
""" % WIN7_VERSION
    executer([str(python), "-c", inspect.cleandoc(programme)],
             "validation du rétroportage blinkpy")


def verifier_webrtc_win7(python: Path) -> None:
    """Ne laisse pas un import natif manquant masquer WebRTC derrière MSE."""
    programme = """
from importlib.metadata import version
import blink_webrtc
from aiortc import RTCCertificate
from av import CodecContext
import pylibsrtp

attendues = {
    "aiortc": "1.9.0", "av": "12.3.0", "cryptography": "42.0.8",
    "pyOpenSSL": "24.1.0", "pylibsrtp": "0.10.0",
}
for paquet, attendue in attendues.items():
    obtenue = version(paquet)
    assert obtenue == attendue, (paquet, obtenue, attendue)
assert blink_webrtc.DISPONIBLE, "WebRTC absent du profil Win7"
assert RTCCertificate.generateCertificate().getFingerprints()
assert CodecContext.create("h264", "r") is not None
print("WebRTC Win7 : imports natifs, certificat DTLS et decodeur H.264 OK")
"""
    executer([str(python), "-c", inspect.cleandoc(programme)],
             "validation des dépendances WebRTC Windows 7")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument(
        "--propre",
        action="store_true",
        help="supprimer l'environnement et les sorties de ce profil avant de commencer",
    )
    parser.add_argument(
        "--win7",
        action="store_true",
        help="construire l'édition legacy Windows 7 (CPython 3.8.10 x64)",
    )
    args = parser.parse_args()

    if args.win7:
        verifier_interpreteur_win7()
    venv, travail, dist = _chemins(args.win7)
    python = _python(venv)
    sortie = dist / "blink2video"

    if args.propre:
        for dossier in (venv, travail, dist):
            if dossier.exists():
                print(f"Suppression de {dossier.name}...")
                shutil.rmtree(dossier, ignore_errors=True)

    if not python.exists():
        executer([sys.executable, "-m", "venv", str(venv)],
                 "création de l'environnement de construction")

    if args.win7:
        verifier_python_win7(python)
        installer_win7(python, travail)
        verifier_blinkpy_win7(python)
        verifier_webrtc_win7(python)
    else:
        executer([str(python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
                 "mise à jour de pip")
        executer([str(python), "-m", "pip", "install", "--quiet",
                  "-r", str(REQUIREMENTS)],
                 "installation des dépendances")

    ffmpeg, ffprobe = ffmpeg_utilisable(python, travail)
    print(f"\nffmpeg embarqué : {ffmpeg}")
    os.environ["BLINK_FFMPEG"] = ffmpeg
    os.environ.pop("BLINK_FFPROBE", None)
    if ffprobe:
        print(f"ffprobe embarqué : {ffprobe}")
        os.environ["BLINK_FFPROBE"] = ffprobe
    os.environ.pop("BLINK_BUILD_TARGET", None)
    if args.win7:
        os.environ["BLINK_BUILD_TARGET"] = "windows7"

    commande = [
        str(python), "-m", "PyInstaller", "--noconfirm", "--clean",
        "--distpath", str(dist), "--workpath", str(travail), str(SPEC),
    ]
    executer(commande, "construction du bundle")

    executable = sortie / ("blink2video.exe" if sys.platform == "win32" else "blink2video")
    if not executable.exists():
        raise SystemExit(f"Bundle introuvable : {executable}")
    if args.win7:
        executer([str(python), str(BASE_DIR / "verify_win7_bundle.py"), str(sortie)],
                 "contrôle statique du bundle Windows 7")

    taille = sum(f.stat().st_size for f in sortie.rglob("*") if f.is_file())
    print(f"\nBundle construit : {sortie}")
    print(f"  exécutable : {executable.name}")
    print(f"  taille     : {taille / 1024 / 1024:.0f} Mo")
    print("\nLes vidéos (Blink_Clips, Blink_Daily…) iront dans Documents/blink2video,\n"
          "l'état (réglages, session) dans le dossier de données d'application du\n"
          "système, ou les deux dans le dossier désigné par la variable BLINK_HOME.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())