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
@propertyâ getter (lecture)@xxx.setterâ validation Ă l'Ă©criture- Sans setter â attribut read-only
- Convention : valeur stockée dans
_attribut(underscore = privé) - L'appel reste
r.energie = 0, pasr.set_energie(0)
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 :
pkg:pypi/requests@2.28.1pkg:composer/laravel/framework@10.0.0pkg:golang/github.com/gin-gonic/gin@v1.9.0pkg:npm/express@4.18.0
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)
{
"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 :
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()
--- 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()
$ python -m unittest tests.test_opener
.........
----------------------------------------------------------------------
Ran 9 tests in 0.002s
OK