5.1 Rendre pyCycloneFlow subprocess-friendly

Pour que flowscope (Go) puisse appeler pyCycloneFlow et lire son rĂ©sultat, notre outil doit respecter un contrat Unix prĂ©cis. Ce n'est pas une question de Python — c'est une convention universelle que tout outil en ligne de commande doit connaĂźtre.

1. Le contrat d'un outil CLI

Quand un programme en appelle un autre, les deux se parlent via trois canaux standards :

CanalUsage conventionnelExemple
stdinDonnées en entrée (si besoin)cat file.txt | monoutil
stdoutDonnĂ©es de sortie — ce que l'appelant litle JSON SBOM
stderrLogs, erreurs, messages humainsERROR: fichier introuvable

Et un quatriÚme élément crucial : le code de retour (exit code).

sys.exit(0)   # succĂšs — l'appelant sait que tout s'est bien passĂ©
sys.exit(1)   # erreur — l'appelant sait qu'il ne doit pas utiliser stdout

C'est la convention Unix depuis les annĂ©es 70. En PHP CLI tu as exit(0) et exit(1) — c'est identique.

La rĂšgle Ă  ne jamais violer :
Si tu Ă©cris du JSON sur stdout → RIEN d'autre ne doit aller sur stdout.
Les logs, les messages de progression, les warnings → stderr uniquement.
Sinon l'appelant reçoit un mélange invalide qu'il ne peut pas parser.

2. Pourquoi stdout plutĂŽt qu'un fichier ?

La version actuelle de pyCycloneFlow écrit toujours dans un fichier (sbom.json). Pour que flowscope l'appelle, deux options :

# Option A — fichier temporaire (lourd)
# Go appelle : py main.py --pip scanner.txt -o /tmp/sbom_xyz.json
# Go lit ensuite le fichier, puis le supprime
# ProblĂšmes : gestion des fichiers temporaires, droits, nettoyage

# Option B — stdout (propre)
# Go appelle : py main.py --pip scanner.txt -o -
# Go lit directement ce que Python écrit sur stdout
# Pas de fichier temporaire, pas de nettoyage

On adopte la convention Unix : - signifie "stdout" pour le paramĂštre de fichier de sortie. Tu la retrouves partout : curl -o -, ffmpeg -i video.mp4 -o -, etc.

Bonus : le pipe fonctionne immédiatement depuis le terminal :

py main.py --pip scanner.txt -o - | python -m json.tool
py main.py --pip scanner.txt -o - | jq '.components | length'

3. Les modifications — models/sbom.py

Une seule modification dans to_json_file() : si le chemin est "-", écrire sur sys.stdout au lieu d'ouvrir un fichier.

# models/sbom.py
import json, sys, uuid
from datetime import datetime, timezone
from .component import Component

class SBOM:
    def __init__(self, components: list[Component]):
        self.components = components

    def to_cyclonedx_json(self) -> dict:
        return {
            "bomFormat": "CycloneDX",
            "specVersion": "1.7",
            "serialNumber": f"urn:uuid:{uuid.uuid4()}",
            "metadata": {
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "tools": [{"name": "pyCycloneFlow", "version": "0.1.0"}]
            },
            "components": [
                {"type": "library", "name": c.name, "version": c.version, "purl": c.purl}
                for c in self.components
            ]
        }

    def to_json_file(self, path: str) -> None:
        data = self.to_cyclonedx_json()
        if path == "-":
            json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
        else:
            with open(path, "w", encoding="utf-8") as f:
                json.dump(data, f, indent=2, ensure_ascii=False)

La logique reste dans to_cyclonedx_json() — to_json_file() ne fait que dĂ©cider oĂč Ă©crire. Le sys.stdout est un objet fichier comme un autre pour json.dump().

4. Les modifications — main.py

Deux ajouts : détecter le mode stdout pour rediriger les logs, et sys.exit() pour signaler le résultat à l'appelant.

# main.py
import argparse, pathlib, logging, sys
from models import SBOM
from parsers import parse_pip

def load_file(path: str) -> str:
    try:
        return pathlib.Path(path).read_text(encoding="utf-8")
    except FileNotFoundError:
        logging.error(f"Fichier introuvable : {path}")
        return ""
    except PermissionError:
        logging.error(f"Permission refusée : {path}")
        return ""
    except Exception as e:
        logging.error(f"Erreur inattendue : {e}")
        return ""

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="pycycloneflow",
        description="GénÚre un SBOM CycloneDX v1.7 depuis plusieurs écosystÚmes"
    )
    p.add_argument("--pip", metavar="FILE", help="Fichier pip list (sortie de 'pip list')")
    p.add_argument(
        "-o", "--output",
        default="sbom.json",
        metavar="FILE",
        help="Fichier de sortie (défaut: sbom.json, '-' pour stdout)"
    )
    p.add_argument("-v", "--verbose", action="store_true", help="Affiche les détails")
    return p

def main():
    args = build_parser().parse_args()
    stdout_mode = args.output == "-"

    # En mode stdout : logs sur stderr pour ne pas polluer le JSON
    level = logging.DEBUG if args.verbose else logging.WARNING
    log_target = sys.stderr if stdout_mode else sys.stdout
    logging.basicConfig(level=level, format='%(levelname)s: %(message)s', stream=log_target)

    components = []

    if args.pip:
        raw = load_file(args.pip)
        components.extend(parse_pip(raw))

    if not components:
        print("Aucun fichier fourni. Utilisez --pip FILE", file=sys.stderr)
        sys.exit(1)   # ← signale l'Ă©chec Ă  l'appelant

    sbom = SBOM(components)
    sbom.to_json_file(args.output)   # écrit sur stdout si args.output == "-"

    if not stdout_mode:
        print(f"✓ {len(components)} composants → {args.output}")
        if args.verbose:
            for c in components:
                print(f"  ● {c}  →  {c.purl}")

    # sys.exit(0) est implicite — Python sort avec 0 si aucune exception

if __name__ == "__main__":
    main()

Le point clĂ© : stdout_mode = args.output == "-" conditionne tout le reste. En mode stdout, mĂȘme @timer (qui print sur stdout) deviendrait une pollution — on corrige ça dans la section suivante.

5. Les modifications — utils/decorators.py

Le décorateur @timer utilisait print(), qui écrit sur stdout. Un seul caractÚre à changer :

# utils/decorators.py
import time, sys, functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        debut = time.perf_counter()
        result = func(*args, **kwargs)
        duree = time.perf_counter() - debut
        print(f"[timer] {func.__name__:<20} {duree:.4f}s", file=sys.stderr)  # ← stderr
        return result
    return wrapper

Sans ce fix, py main.py --pip scanner.txt -o - produirait :

[timer] parse_pip            0.0001s
{
  "bomFormat": "CycloneDX",
  ...

Le JSON est prĂ©cĂ©dĂ© d'une ligne parasite — tout parseur JSON en aval Ă©choue. Avec file=sys.stderr, le timer est visible dans le terminal (les deux flux s'y mĂ©langent visuellement) mais il n'entre jamais dans le pipe. Pour le vĂ©rifier :

py main.py --pip scanner.txt -o - 2>$null        # PowerShell (Windows)
py main.py --pip scanner.txt -o - 2>/dev/null    # bash (Linux / macOS)
Note Windows / PowerShell

Dans le terminal Windows, stdout et stderr s'affichent tous les deux — le [timer] est donc toujours visible visuellement mĂȘme aprĂšs le fix. C'est normal : PowerShell ne les sĂ©pare pas Ă  l'Ă©cran.

Ce qui compte c'est ce qui entre dans le pipe. La commande 2>$null redirige stderr vers nulle part et te montre ce que l'appelant Go recevra réellement : du JSON pur, sans [timer].

6. Test d'intĂ©gration — test_opener.py

On ajoute deux tests dans la section Phase 5 de tests/test_opener.py. Contrairement aux tests unitaires des phases prĂ©cĂ©dentes, ceux-ci lancent main.py comme un vrai subprocess — ils testent le contrat complet, pas une fonction isolĂ©e.

# tests/test_opener.py — section Phase 5
def test_stdout_est_json_valide(self):
    import subprocess, json
    cwd = str(pathlib.Path(__file__).parent.parent)
    result = subprocess.run(
        ["py", "main.py", "--pip", FIXTURE, "-o", "-"],
        capture_output=True, text=True, cwd=cwd
    )
    self.assertEqual(result.returncode, 0)
    sbom = json.loads(result.stdout)   # lĂšve si stdout n'est pas du JSON pur
    self.assertEqual(sbom["bomFormat"], "CycloneDX")

def test_stdout_sans_pollution_timer(self):
    import subprocess, json
    cwd = str(pathlib.Path(__file__).parent.parent)
    result = subprocess.run(
        ["py", "main.py", "--pip", FIXTURE, "-o", "-"],
        capture_output=True, text=True, cwd=cwd
    )
    self.assertNotIn("[timer]", result.stdout)
    json.loads(result.stdout)   # lÚve ValueError si stdout est pollué

Points clés :

Lancer les tests :
py -m pytest tests/           # tous les tests
py -m pytest tests/ -v -k "subprocess"   # Phase 5 seulement

7. Test en console

Mode fichier (comportement inchangé) :
$ py main.py --pip scanner.txt
[timer] parse_pip            0.0004s
✓ 3 composants → sbom.json
Mode stdout — le JSON arrive directement :
$ py main.py --pip scanner.txt -o -
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.7",
  "serialNumber": "urn:uuid:a1b2c3...",
  "metadata": { ... },
  "components": [
    {"type": "library", "name": "requests", "version": "2.28.1", "purl": "pkg:pypi/requests@2.28.1"},
    ...
  ]
}
Le [timer] de parse_pip va sur stderr — il n'apparaüt pas dans stdout, donc il ne pollue pas le JSON.
En pipe avec jq :
$ py main.py --pip scanner.txt -o - | jq '.components | length'
3

$ py main.py --pip scanner.txt -o - | jq '.components[0].purl'
"pkg:pypi/requests@2.28.1"
Cas d'erreur — exit code 1 :
$ py main.py -o -
Aucun fichier fourni. Utilisez --pip FILE   ← sur stderr
$ echo $?
1   ← exit code non-zero : Go sait que stdout ne contient pas de JSON valide

pyCycloneFlow est maintenant un citoyen Unix de premiĂšre classe. Le chapitre suivant montre comment flowscope (Go) l'appelle et comment Python peut lui-mĂȘme appeler des processus externes avec subprocess.

Chapitre précédent Chapitre suivant : subprocess