3.4 Refactorisation : Component complet + SBOM

En PHP, les getters/setters sont de simples mĂ©thodes : getEnergie(), setEnergie(). En Python, le concept existe mais passe par les dĂ©corateurs : @property n'est pas lĂ  pour faire joli — il dit Ă  Python "cette mĂ©thode est le getter", et @xxx.setter dit "celle-lĂ  est le setter". C'est ce qui fait que r.energie = 0 appelle automatiquement le setter au lieu d'Ă©craser l'attribut brut. On illustre d'abord sur un exemple fictif, puis on applique ça Ă  Component dans pyCycloneFlow.

1. Les Properties (@property)

Par dĂ©faut, rien n'empĂȘche d'assigner n'importe quelle valeur Ă  un attribut. Les properties permettent d'ajouter de la validation sans changer l'interface publique.

class Robot:
    def __init__(self, modele: str, energie: int):
        self.modele = modele
        self.energie = energie  # passe par le setter

    @property
    def energie(self) -> int:
        return self._energie    # stocké dans _energie

    @energie.setter
    def energie(self, value: int):
        if value < 0:
            raise ValueError("L'Ă©nergie ne peut pas ĂȘtre nĂ©gative")
        self._energie = value

    @property
    def statut(self) -> str:    # read-only : pas de setter
        return "actif" if self._energie > 0 else "hors service"

r = Robot("T-800", 100)
print(r.statut)   # actif
r.energie = 0
print(r.statut)   # hors service
r.energie = -10   # ValueError: L'Ă©nergie ne peut pas ĂȘtre nĂ©gative

2. Component refactorisé

On ajoute ecosystem (d'oĂč vient la dĂ©pendance) et purl en read-only property. Le PURL (Package URL) est un standard industriel qui identifie chaque composant sans ambiguĂŻtĂ© : pkg:<ecosystem>/<name>@<version>.

class Component:
    def __init__(self, name: str, version: str, ecosystem: str = "pypi"):
        self.name = name
        self.version = version      # passe par le setter
        self.ecosystem = ecosystem

    @property
    def version(self) -> str:
        return self._version

    @version.setter
    def version(self, value: str):
        if not value or not value.strip():
            raise ValueError("La version ne peut pas ĂȘtre vide")
        self._version = value.strip()

    @property
    def purl(self) -> str:
        return f"pkg:{self.ecosystem}/{self.name}@{self.version}"

    def __str__(self) -> str:
        return f"{self.name} == {self.version}"

    def __repr__(self) -> str:
        return f"Component('{self.name}', '{self.version}', '{self.ecosystem}')"

    def __eq__(self, other) -> bool:
        return (self.name == other.name
                and self.version == other.version
                and self.ecosystem == other.ecosystem)

c = Component("requests", "2.28.1")
print(c)        # requests == 2.28.1
print(repr(c))  # Component('requests', '2.28.1', 'pypi')
print(c.purl)   # pkg:pypi/requests@2.28.1

Component("flask", "")  # ValueError: La version ne peut pas ĂȘtre vide

Exemples de PURLs par écosystÚme :

3. SBOM — export CycloneDX v1.7

La classe SBOM agrÚge les composants et génÚre le JSON. On utilise deux modules stdlib : uuid pour un identifiant unique par BOM, et datetime pour l'horodatage UTC.

import json
import uuid
from datetime import datetime, timezone

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:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(self.to_cyclonedx_json(), f, indent=2, ensure_ascii=False)
sbom.json généré :
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.7",
  "serialNumber": "urn:uuid:4f3a2b1c-...",
  "metadata": {
    "timestamp": "2026-05-05T10:00:00+00:00",
    "tools": [{"name": "pyCycloneFlow", "version": "0.1.0"}]
  },
  "components": [
    {
      "type": "library",
      "name": "requests",
      "version": "2.28.1",
      "purl": "pkg:pypi/requests@2.28.1"
    }
  ]
}

Vue d'ensemble

Les deux classes et leur relation :

classDiagram direction LR class Component { +str name +str ecosystem -str _version +str version +str purl +__str__() str +__repr__() str +__eq__(other) bool } note for Component "version → getter + setter _version → valeur rĂ©elle stockĂ©e purl → read-only" class SBOM { +list~Component~ components +to_cyclonedx_json() dict +to_json_file(path) None } SBOM "1" *-- "1..*" Component : contient

4. main.py — version finale Phase 3

import pathlib, re, logging, json, uuid
from datetime import datetime, timezone


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


class Component:
    def __init__(self, name: str, version: str, ecosystem: str = "pypi"):
        self.name = name
        self.version = version
        self.ecosystem = ecosystem

    @property
    def version(self) -> str:
        return self._version

    @version.setter
    def version(self, value: str):
        if not value or not value.strip():
            raise ValueError("La version ne peut pas ĂȘtre vide")
        self._version = value.strip()

    @property
    def purl(self) -> str:
        return f"pkg:{self.ecosystem}/{self.name}@{self.version}"

    def __str__(self) -> str:  return f"{self.name} == {self.version}"
    def __repr__(self) -> str: return f"Component('{self.name}', '{self.version}', '{self.ecosystem}')"
    def __eq__(self, other) -> bool:
        return self.name == other.name and self.version == other.version and self.ecosystem == other.ecosystem


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:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(self.to_cyclonedx_json(), f, indent=2, ensure_ascii=False)


def parse_scan_data(raw_data: str) -> list[Component]:
    components = []
    if not raw_data:
        return components
    for line in raw_data.strip().split('\n'):
        if "Package" in line or "---" in line:
            continue
        match = re.search(r"(\S+)\s+([\d\.]+)", line)
        if match:
            components.append(Component(name=match.group(1), version=match.group(2)))
        else:
            logging.warning(f"Ligne ignorée : {line}")
    return components


def main():
    logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
    print("--- pyCycloneFlow : Safe Opener ---")

    raw = load_scan_file("scanner.txt")
    inventory = parse_scan_data(raw)

    sbom = SBOM(inventory)
    sbom.to_json_file("sbom.json")

    print(f"--- Inventaire pyCycloneFlow ({len(inventory)} items) ---")
    for item in inventory:
        print(f" ● {item}  →  {item.purl}")
    print("✓ sbom.json gĂ©nĂ©rĂ©")

if __name__ == "__main__":
    main()
Résultat console :
--- pyCycloneFlow : Safe Opener ---
--- Inventaire pyCycloneFlow (3 items) ---
 ● requests == 2.28.1  →  pkg:pypi/requests@2.28.1
 ● flask == 2.2.2  →  pkg:pypi/flask@2.2.2
 ● cryptography == 38.0.1  →  pkg:pypi/cryptography@38.0.1
✓ sbom.json gĂ©nĂ©rĂ©

5. Tests mis Ă  jour

import unittest
from main import load_scan_file, parse_scan_data, Component, SBOM

class TestCycloneFlow(unittest.TestCase):

    # --- Phase 1 ---
    def test_file_not_found(self):
        self.assertEqual(load_scan_file("fichier_imaginaire.txt"), "")

    def test_load_success(self):
        self.assertIn("Package", load_scan_file("scanner.txt"))

    # --- Phase 2 ---
    def test_parsing_retourne_composant(self):
        result = parse_scan_data("my-lib  1.0.0")
        self.assertEqual(len(result), 1)
        self.assertIsInstance(result[0], Component)
        self.assertEqual(result[0].name, "my-lib")

    def test_parsing_ignore_header(self):
        result = parse_scan_data("Package    Version\n---\nrequests  2.28.1")
        self.assertEqual(len(result), 1)

    def test_parsing_chaine_vide(self):
        self.assertEqual(parse_scan_data(""), [])

    # --- Phase 3 : Component ---
    def test_component_str(self):
        self.assertEqual(str(Component("requests", "2.28.1")), "requests == 2.28.1")

    def test_component_purl(self):
        self.assertEqual(Component("requests", "2.28.1").purl, "pkg:pypi/requests@2.28.1")

    def test_component_version_vide(self):
        with self.assertRaises(ValueError):
            Component("requests", "")

    def test_component_eq(self):
        self.assertEqual(Component("requests", "2.28.1"), Component("requests", "2.28.1"))

    # --- Phase 3 : SBOM ---
    def test_sbom_structure_json(self):
        data = SBOM([Component("requests", "2.28.1")]).to_cyclonedx_json()
        self.assertEqual(data["bomFormat"], "CycloneDX")
        self.assertEqual(data["specVersion"], "1.7")
        self.assertIn("serialNumber", data)
        self.assertEqual(data["components"][0]["purl"], "pkg:pypi/requests@2.28.1")

if __name__ == '__main__':
    unittest.main()
Résultat attendu :
$ python -m unittest tests.test_opener
.........
----------------------------------------------------------------------
Ran 9 tests in 0.002s

OK
Chapitre précédent Phase 3 Terminée : Retour au sommaire