4.1 Modules & Packages
En PHP, on organise son code avec namespace App\Models et use. En Python, c'est la structure de fichiers elle-mĂȘme qui crĂ©e les namespaces : un dossier avec un __init__.py devient un package importable. Pas de dĂ©claration de namespace dans le fichier â le chemin est le namespace.
1. Module vs Package
Un module est un simple fichier .py. Un package est un dossier contenant un __init__.py (mĂȘme vide).
# math_utils.py â module
def carre(x):
return x ** 2
# geometrie/ â package
# âââ __init__.py
# âââ cercle.py
# âââ rectangle.py
# geometrie/cercle.py
import math
def aire(rayon: float) -> float:
return math.pi * rayon ** 2
# Utilisation
import math_utils # module entier
from geometrie.cercle import aire # fonction précise
from geometrie import rectangle # sous-module
print(math_utils.carre(4)) # 16
print(aire(5)) # 78.53...
import xâ importe le module, accĂšs viax.trucfrom x import yâ importe directementydans le scope localfrom x import y as zâ alias, pratique pour les noms longs
2. Imports relatifs vs absolus
à l'intérieur d'un package, on peut importer avec un chemin relatif (point = dossier courant) ou absolu (depuis la racine du projet).
# geometrie/cercle.py
# Import absolu â chemin complet depuis la racine
from geometrie.rectangle import perimetre
# Import relatif â . = mĂȘme dossier que cercle.py
from .rectangle import perimetre
- Import absolu : lisible, recommandé pour les entrées publiques
- Import relatif : pratique à l'intérieur d'un package, résiste aux renommages
- Convention : dans un package, on utilise les imports relatifs entre modules du mĂȘme package
3. Le rĂŽle de __init__.py
__init__.py s'exécute dÚs qu'on importe le package. Il sert de façade : on y déclare ce que le package expose publiquement, pour que l'utilisateur n'ait pas à connaßtre l'organisation interne.
# geometrie/__init__.py
from .cercle import aire as aire_cercle
from .rectangle import aire as aire_rectangle
# L'utilisateur peut maintenant écrire :
from geometrie import aire_cercle, aire_rectangle
# Au lieu de :
from geometrie.cercle import aire
- Peut ĂȘtre vide (le dossier est quand mĂȘme un package)
- Contient souvent les re-exports des symboles publics
- Ăquivalent PHP : un
ServiceProviderou un barrel file JS (index.ts)
4. Refactoring pyCycloneFlow
On découpe main.py monolithique en packages. La rÚgle : une responsabilité par fichier.
pyCycloneFlow/
âââ models/
â âââ __init__.py â expose Component et SBOM
â âââ component.py â classe Component
â âââ sbom.py â classe SBOM
âââ parsers/
â âââ __init__.py â expose parse_pip
â âââ pip.py â parser Ă©cosystĂšme pip
âââ tests/
â âââ fixtures/
â â âââ pip_list.txt
â âââ test_opener.py
âââ main.py â orchestration uniquement
# models/component.py
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
# models/sbom.py
import json, 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:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.to_cyclonedx_json(), f, indent=2, ensure_ascii=False)
# models/__init__.py
from .component import Component
from .sbom import SBOM
# parsers/pip.py
import re, logging
from models.component import Component
def parse_pip(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
# parsers/__init__.py
from .pip import parse_pip
# main.py
import pathlib, logging
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 main():
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
print("--- pyCycloneFlow ---")
raw = load_file("scanner.txt")
inventory = parse_pip(raw)
sbom = SBOM(inventory)
sbom.to_json_file("sbom.json")
print(f"--- Inventaire ({len(inventory)} items) ---")
for item in inventory:
print(f" â {item} â {item.purl}")
print("â sbom.json gĂ©nĂ©rĂ©")
if __name__ == "__main__":
main()
--- pyCycloneFlow ---
--- Inventaire (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Ă©
Les imports dans tests/test_opener.py passent de from main import Component Ă from models import Component â c'est tout.
5. Mettre les tests Ă jour
Le refactoring casse les tests existants sur deux points : les imports pointent encore vers main, et le chemin vers le fichier fixture est relatif au rĂ©pertoire d'exĂ©cution â fragile. On corrige les deux.
Avant (phase 3) â tout importĂ© depuis main :
from main import load_scan_file, parse_scan_data, Component, SBOM
AprĂšs â chaque symbole importĂ© depuis son module :
import unittest
import pathlib
from main import load_file
from models import Component, SBOM
from parsers.pip import parse_pip
# Chemin absolu vers la fixture, quel que soit le répertoire d'exécution
FIXTURE = str(pathlib.Path(__file__).parent / "fixtures" / "pip_list.txt")
load_scan_fileâ renommĂ©load_filedansmain.pyparse_scan_dataâ renommĂ©parse_pip, dĂ©placĂ© dansparsers/pip.pyComponentetSBOMâ dansmodels/__file__â chemin absolu du fichier de test en cours ;.parentdonne son dossier
Le fichier tests/fixtures/pip_list.txt remplace l'ancien scanner.txt codé en dur :
Package Version
------------ -------
requests 2.28.1
flask 2.2.2
cryptography 38.0.1
Tests mis Ă jour :
class TestCycloneFlow(unittest.TestCase):
def test_file_not_found(self):
self.assertEqual(load_file("fichier_imaginaire.txt"), "")
def test_load_success(self):
self.assertIn("Package", load_file(FIXTURE)) # fixture Ă chemin absolu
def test_parsing_retourne_composant(self):
result = parse_pip("my-lib 1.0.0") # parse_pip, pas parse_scan_data
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], Component)
def test_parsing_ignore_header(self):
result = parse_pip("Package Version\n---\nrequests 2.28.1")
self.assertEqual(len(result), 1)
def test_parsing_chaine_vide(self):
self.assertEqual(parse_pip(""), [])
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"))
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")
$ python -m unittest tests.test_opener
..........
----------------------------------------------------------------------
Ran 10 tests in 0.003s
OK