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...

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

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

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()
Résultat console :
--- 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")

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")
Résultat attendu :
$ python -m unittest tests.test_opener
..........
----------------------------------------------------------------------
Ran 10 tests in 0.003s

OK
Chapitre précédent Chapitre suivant : Décorateurs